Files
llamamod/notes/editor-render.md
T

22 KiB
Raw Blame History

Level editor – render package (editor.render)

Phase 1 of the level editor (notes/editor-design.md): OpenGL viewport, level rendering, overlays, camera, picking, transform gizmo. Package com.eliasmueller.llamamod.editor.render; it has no dependency on editor.model/editor.io – the UI adapts the scene model to RenderScene.

montage

Build / platform

  • Gradle: LWJGL 3.4.2 (lwjgl, lwjgl-opengl, lwjgl-jawt, lwjgl-egl) + lwjgl3-awt 0.2.5 (AWTGLCanvas, a heavyweight AWT canvas inside Swing; transitive = false, its POM pulls Vulkan/macOS natives) + JOML 1.10.8 (math). Natives natives-linux and natives-windows of lwjgl and lwjgl-opengl are runtime dependencies, so the fat jar (and with it both packages) carries linux/x64/…/*.so and windows/x64/…/*.dll; LWJGL extracts them to a temp dir at runtime. Fat jar 9.7 MB.
  • Fat jar manifest gets Multi-Release: true (LWJGL is a multi-release jar), module-info.class files are excluded. jlink modules + jdk.unsupported (LWJGL/JOML use sun.misc.Unsafe). packageLinux/packageWindows otherwise unchanged.
  • OpenGL 3.3 core profile, no 2.1 fallback (decision): shaders are GLSL 330, texture pages are one GL_TEXTURE_2D_ARRAY, VAOs/VBOs. 3.3 is supported by every GPU/driver since ~2010 (Intel HD 2000+, Mesa llvmpipe gives 4.5); a 2.1 path would double the renderer for hardware that cannot run the rest of the toolchain comfortably anyway. The canvas first asks for 4× MSAA and retries without multisampling; if no 3.3 context can be created the viewport shows a message instead of crashing (Viewport.glError()). No swap interval is requested (Xvfb/GLX has no swap control, lwjgl3-awt then fails context creation).
  • Tested:
    • Linux native (Xvfb + llvmpipe, DISPLAY=:8): fat jar and the jlinked LlamaMod-linux-x64 runtime – rendering, picking, gizmo drags, fly mode via xdotool.
    • Windows package under Wine 6.0.3 (own win64 prefix, JDK_JAVA_OPTIONS=-XX:ActiveProcessorCount=4, runtime\bin\java.exe -cp LlamaMod.exe …ViewerDemo): Wine's WGL gives a 3.3 core context on the host llvmpipe; the screenshot is pixel-identical to the Linux one. LlamaMod.exe --mods still works. Real Windows hardware not tested.
  • -Dllamamod.editor.msaa=0 disables multisampling (2–3× faster on software GL).

Demo

java -cp build/libs/llamamod-*-all.jar com.eliasmueller.llamamod.editor.render.ViewerDemo \
     <level.wad | testworld> [--cd DIR] [--size 1280x720] [--collision] [--no-markers]
     [--hide waypoints,zones] [--view top|front|side] [--cam x,y,z,yawDeg,pitchDeg]
     [--select o12,t5,w3] [--frame] [--gizmo translate|rotate|scale] [--shot out.png] [--bench] [--list]

testworld builds the Testwelt (TestWorldMod) from the CD's village WAD. The window has layer check boxes, view and gizmo mode selectors and a status bar (hover/pick info). --list prints all strats with ids and positions. --bench orbits 120 frames and prints timings.

Controls (Viewport)

Navigation presets (Navigation.Preset, View › Navigation, Preferences › Viewport & navigation, Help › Viewport navigation; setting viewnav.preset, default Fusion 360):

Input Fusion 360 (default) Blender Unity / Unreal
middle drag pan orbit pan
Shift+middle drag orbit pan pan
Ctrl+middle drag zoom zoom zoom
double-click middle fit all – –
Alt+left drag orbit orbit orbit
right drag look around + W/A/S/D move, Q/E down/up, Shift ×4, wheel = fly speed (all presets)
Alt+right drag zoom (all presets)
wheel smooth exponential zoom towards the cursor (all presets)
Input Action
view cube (top right) click a face / edge / corner = look from there (animated, pivot and projection kept), drag = orbit, house = home (iso view from front-right-top + frame all)
F / Home frame selection / frame all tiles (smooth 220 ms transition)
numpad 7 / 1 / 3 / 5 top / front / side (orthographic) / toggle perspective
W / E / R, Q (when not flying) gizmo translate / rotate / scale, toggle world/local
left click pick (listener); left drag on a gizmo handle = transform; Ctrl = invert snapping; Esc = cancel drag
  • Zoom (2026-11-07): constant percentage per wheel notch (zoomFactor, default 12 %, 2–40 %, "invert zoom"), fractional touchpad deltas scale smoothly, animated 120 ms (ease-out; further notches accumulate on the target). Anchor (anchorDistance): the depth-buffer hit under the cursor if it lies in front of the near plane and closer than 3× the pivot depth (and the far plane), else the current pivot depth along the cursor ray – pointing at the sky or a far cliff no longer makes a huge step, a near wall no longer stalls. The anchor becomes the new pivot depth. Old behaviour: a fixed 0.15 × distance dolly step along the ray, independent of what is under the cursor (sky/near ground felt extremely fast/slow).
  • Orbit: pivot = surface point under the cursor at drag start (fallback: selection centre, then view centre), shown as a small RGB cross; the camera keeps its camera-space offset to the pivot, so the pivot stays under the cursor; constant angular speed (default 0.35 °/px), pitch clamped to ±89°. Camera.viewMatrix now uses the mirrored yaw direction as up vector when looking straight up, so pitching across the vertical threshold no longer flips the picture.
  • Pan moves the grabbed point (its view depth) exactly with the cursor (sky: pivot depth).
  • Viewport API: setNavigation/zoomAt/setZoomSpeed/setInvertZoom/setOrbitSpeed/setViewCubeVisible/ home/snapView; math in Navigation and ViewCube (pure, NavigationTest, ViewCubeTest). Plugin tools.viewnav.ViewNavEditorPlugin (menus, preferences dialog, cheat sheet; the status bar shows a one-line summary on open and when the preset changes).

Orbiting a standard ortho view switches back to perspective. Hover picking runs at most every 40 ms while the mouse moves (setHoverPicking(false) turns it off).

view cube, preferences

API

RenderScene scene = LevelSceneAdapter.fromWad(wadBytes).scene();   // or built by the editor UI
Viewport vp = new Viewport();               // JPanel; add to any Swing layout
vp.setScene(scene);
vp.setGrid(0, new float[] {0, -H, W, 0});   // grid plane y and bounds (x0, z0, x1, z1)
vp.setLayerVisible(LevelSceneAdapter.LAYER_COLLISION, true);
vp.setSelection(List.of(PickId.of(PickId.OBJECT, 12)));            // highlight (+ unbatched tiles)
vp.gizmo().setVisible(true); vp.gizmo().setTarget(matrix); vp.gizmo().setMode(Gizmo.Mode.ROTATE);
vp.gizmo().setSnap(new Gizmo.Snap(true, 0.25f, 15f, 0.1f));         // grid snapping hook
vp.addViewportListener(new ViewportListener() {
    public void picked(PickEvent e) { … e.id(), e.world(), e.modifiers(), e.clickCount() … }
    public void hovered(int id) { … }
    public void gizmo(GizmoEvent e) { … }                            // START / DRAG / END / CANCEL
    public void cameraChanged(Camera c) { … }
});
vp.frameAll(); vp.frameSelection(); vp.setView(Camera.View.TOP);
vp.pick(x, y); vp.surfacePoint(x, y);       // synchronous id buffer / depth reads
vp.screenshot(); vp.stats(); vp.glInfo(); vp.requestRender(); vp.dispose();

Scene data (render-facing interface)

  • RenderScene: pages (TexturePage(w, h, int[] 0xRRGGBBAA)), instances, markers, overlays (world-space Overlay per layer name), background (0xRRGGBB, also fog colour). After edits: changed(instance) (rebuilds only that instance's chunk), changed() (everything, markers/overlays), texturesChanged(). Modify on the Swing thread.
  • RenderMesh: triangle mesh in local space – per vertex positions/normals, per triangle corner UVs (normalised to the page), per triangle page (-1 = untextured), colour (multiplied, flat colours / translucency), flags (DOUBLE_SIDED, NO_VERTEX_COLOR, LIT = shade with normals) and blend class (BLEND_OPAQUE = alpha-tested cut-out, BLEND_ALPHA, BLEND_ADD). Optional overlay = local-space helper geometry that follows every instance (collision).
  • RenderInstance(id, kind, mesh): transform (JOML Matrix4f), vertexColors (game convention 0x80 = 1.0, doubled with clamp), tint, visible, layer. Kind TILE instances are baked into static batches; OBJECT/PREVIEW are drawn individually with their matrix.
  • Marker: pickable icon (cube/diamond/flat arrow, pole, direction tick, colour, layer, label).
  • Overlay: coloured lines + translucent triangles (box, line, triangle).
  • Pick ids are plain ints, 0 = nothing; PickId is the convention used by the adapter (kind << 24 | index: TILE, OBJECT, WAYPOINT, ZONE, LIGHT, USER…).
  • Legend: collision colours by material/slope and marker category colours, same as notes/debugviz.md (Legend.category(stratName)).

Gizmo

Gizmo is pure math (unit-tested in GizmoTest): modes TRANSLATE (3 axes, 3 planes, centre = screen plane), ROTATE (3 rings, back halves faded, edge-on rings fall back to screen angle), SCALE (3 axes in local space, centre = uniform). World or local space (scale always local). Handles keep a constant screen size (sizePx, default 95). Hit testing in screen space (9 px). GizmoEvent carries the total delta since drag start (translation, axis+angle about the start pivot, scale factors) plus the start matrix: e.result() = new target transform, e.applyTo(m) for other selected objects. The UI shows a live preview on DRAG, emits one undoable command on END and restores e.start() on CANCEL; it calls gizmo().setTarget() so the handles follow (axes stay as at drag start). Snapping: Snap(enabled, translate step, rotate degrees, scale step), Ctrl inverts enabled while dragging.

Coordinate conventions (handedness fix, 2026-11-06)

  • The view is left-handed like the game's Direct3D renderer. The engine draws export-space data (tile floats, TRAK/STPC vertices, strat (x, y, −z)) with a D3D camera, so a plain OpenGL view of the same coordinates was the game's picture mirrored left-right (owner report: "levels mirrored"). Camera.viewMatrix() = scale(−1, 1, 1) · lookAt(…); the renderer culls with glFrontFace(GL_CW); data, picking (ray/project use the matrices) and the scene model are unchanged.
  • Camera.yaw/pitch equal the game camera angles (0x5790a4/0x5790a0, 4096 = 2π): yaw 0 looks along game +z (export −z), yaw 90° along +x, positive yaw turns left. forward() = (sin y·cos p, sin p, −cos y·cos p) (export), right() = screen right = (−cos y, 0, −sin y) (game: (−cos y, sin y), as found by the freecam mod), up() = forward × right. Fly look/orbit: dragging right decreases yaw. ViewerDemo --cam x,y,−gz,yawDeg,pitchDeg [--fov vDeg] reproduces a game camera.
  • Rotate gizmo: the edge-on fallback uses the screen angle with the mirrored sign.
  • Standard views: top = x to the right, game z (grid rows) downwards (cell (0,0) top left, as the MAP/region grid is laid out; yaw π); front looks along game −z (x right); side looks along −x.
  • Files (Import mesh, OBJ and glTF export, the Python geom tools) are right-handed y-up game space (= export space with z negated and the winding reversed), so they look the same in Blender as in the editor and the game (2026-11-07; before, they were export space and appeared mirrored – see editor-tools-geometry.md "Handedness").
  • Spaces: render/export space = (x, y, −z_game); model and inspector values (strats, waypoints, zones, lights, collision) are game space, tiles store export floats. LGHT point lights/directions are stored in game space (engine negates z on load). Strat yaw: rotateY(−ry·2π/4096) in export space with model front = local −z → yaw 0 faces game +z, 1024 faces +x (verified: llama ry 3061 and Pacha at his runtime yaw 830 face the same way as in game). Tiles: rotateY(−rot).
  • Evidence: in-game screenshots (apptest + freecam, camera read with winedbg from 0x5790b0 and the look-at target 0x6d9d80) vs. ViewerDemo from the same camera: img/editor/fix_compare_start.jpg (game / before / after), fix_compare_back.jpg, fix_compare_yaw.jpg, fix_compare_top.jpg. The game's horizontal FOV matches 60° vertical at 16:9; its vertical FOV is ~15 % smaller (non-uniform projection, not reproduced). Regression test: CameraHandednessTest (projected pixel x of the llama and Pacha against the game screenshots, axis conventions).

Rendering details

  • Batching: tile instances are pre-transformed into 8×8-cell chunks; each chunk is one VBO (36-byte vertices: position, normal, UV, colour, pick id, page layer + flags) sorted into 6 ranges (blend class × double-sided) plus a line VBO (collision outlines/fills). Frustum culling per chunk AABB / per object (Culling, below). All pages live in one texture array (+1 white layer for flat colours), so a chunk needs at most 6 draw calls. Objects use one VBO per mesh. Selected tiles are taken out of their chunk and drawn alone, so dragging them never rebuilds a chunk; changed(instance) on a tile rebuilds its chunk (~20k vertices, a few ms).
  • Passes: opaque (alpha test, back-face culling except double-sided faces), back sides (below), overlay lines, translucent (alpha, then additive; depth write off, not sorted), overlay fills, selection (orange tint + hidden-line ghost + wireframe; hover = pale wireframe), gizmo (depth cleared). Optional distance fog (40–160 cells) in the level's backdrop colour.
  • Picking: id-buffer pass into an offscreen RGBA8/depth FBO (scissored to a 9×9 window, the nearest non-zero pixel wins so small markers are easy to hit), depth → world hit point. Cut-out texels are not pickable (click through leaves).
  • Textures: TEXT pages ABGR1555 → RGBA, 0x0000 transparent, STP=0 = half alpha on blend pages (page class 4/5/7); transparent texels are dilated (no dark fringes), 3 mip levels. Blend class per face: texture entry bits 12–14 (1/3 → alpha, 2/4 → additive) or, for mode 0, page class (4/7 alpha, 5 additive) – an approximation of the engine's table at 0x573eb8; flat colour entries (bit 0) use u0/u1/v0 as RGB, alpha in v1 when a blend mode is set.

Culling and "disappearing objects" (2026-11-07)

Owner report: objects, especially large ones, disappear at the screen edge.

  • Root cause: not the frustum culling but the Environment plugin's fog preview (on by default): it sets a FogOverride with clip + aroundPivot, and the mesh shader discarded every fragment farther than the fog end from the orbit pivot (radial). The pivot is the view centre, so geometry towards the screen edges was cut away, and it popped in/out while panning. Evidence: img/editor/cull_root_fogclip.jpg (old build, same camera: fog preview on / off – the left islands are missing with it on). Fix (render package only): with aroundPivot the shader draws a desaturated ghost beyond the radius instead of discarding (uClipGhost); the eye-based far clip is unchanged.
  • Frustum culling itself was correct (JOML plane extraction is orientation-independent; the mirrored view only swaps the left/right planes). Hardened anyway: Culling boxes include every vertex and the instance's overlay (collision lines/fills – a collision-only chunk had an empty box and was always culled), objects use their local box transformed with the full instance matrix (rotation/scale, 8 corners), skinned characters are culled with their posed vertices, + a small absolute/relative margin. -Dllamamod.editor.noCull=true disables culling (diagnostics).
  • Tests: CullingTest – random perspective/ortho cameras (any yaw/pitch, fov 30–100°, aspect), random rotated/scaled meshes + every village instance: whenever any vertex projects inside the clip volume the box must be visible (no false culls). Pixel check: ViewerDemo village, city (t1l6m003) and Testwelt from inside the level at 8 yaws each, culling on vs noCull: 0 differing pixels in all 24 pairs (img/editor/cull_overview.jpg).

Back sides of one-sided faces

The game culls faces without flag 0x400 from behind; the editor now shows those back sides (View › Show back faces, default on; opacity in Viewport preferences, default 30 %): pass 3 after the opaque pass draws the single-sided opaque/cut-out ranges again with glCullFace(GL_FRONT), depth-tested without depth writes, blended, darkened + desaturated with a fine screen-space diagonal hatch. Front faces look exactly as before (the pass only fills pixels whose depth is not covered). Translucent one-sided faces (island skirts) get no back side (it showed through them everywhere). Picking hits both sides; the shader sets bit 31 of the pick id for a back side (PickHit.backFace, Viewport.lastPickBackFace()/hoverBackFace()), the status bar appends "(back side)" to the hovered item. img/editor/cull_backfaces.jpg (one-sided cliff walls seen from behind: game look / editor).

Level adapter (LevelSceneAdapter.fromWad)

  • Tiles: TRAK model per MAP tile, transform translation(x, y, z).rotateY(-rot) (same as Geometry.TileXform), vertex colours = first colour frame (alt models of WFPC 0x10000 tiles are not shown). UVs via Geometry.faceUvs, face flags 0x400 / 0x4000 honoured.
  • Collision: every TRAK model's polygons (Collision.decode/polygon) as outlines 1 cm above the face, coloured as in debugviz (floor green, slope yellow-green, wall blue, ceiling orange, materials 16/17/18/30/… fixed colours); fills for material 16 (exit floor) and faintly 17/18. Invisible-collision detection (red fill of the in-game debug mod) is not done.
  • Strats: game space → export space (z negated), yaw = ry. Object models: the strat code sets its model with 0xb2 <abs STPC offset of the model header>; 0x54 – StratModels scans the strat from its entry to the next strat's name site and takes the first such pair; spawn stubs (b2 <code> e0) are followed. Characters (skinned models, header +0x84 = 0, nBones × u16 vertex counts) are posed with frame 0 of an animation set with the same bone count (first one referenced by 0xb2 <set> in the strat code, else the first in STPC): bone matrices 4×4 f32, row vectors. Result: villagers, kids, Pacha, Tipo, bunnies look right, the llama is shown in a crouching pose (frame 0 of its set). Village: 20 of 32 placed types get a model (8 of them skinned); the rest are invisible logic objects, except RisingColumns, SwitchPlatformPadVillage and AppearingPlatformsVillage, which set their models some other way (shown as markers).
  • Invisible strats (categories of debugviz: start, exit, checkpoint, camera, trigger, force, secret, misc) become markers on poles (exit: purple cell box); start/checkpoint additionally show their model. Objects without a resolvable model: small grey cube; unposable character: grey diamond.
  • Waypoints: every path node a pickable diamond (layer waypoints), chains (next ≠ 0 → i+1) and strat → first node as lines. Some chains are point lists across the whole level (e.g. the llama's), so the layer gets busy – the UI should show it for the selection only.
  • Environment zones: pink cell boxes + pickable marker (layer zones); the default zone's backdrop colour (file offset 0x4e, 0x00BBGGRR) becomes scene.background (cyan village sky, purple river fog, dark blue city).

Performance (1280×720, llvmpipe software GL on this machine, 4× MSAA / no MSAA)

Level tiles/objects/markers triangles drawn draw calls orbit avg MSAA 4 no MSAA
village t1l1m001 (+collision) 1458 inst., 218 markers 27k 125–145 17–27 ms 9 ms (109 fps)
river t1l3m002 (+collision) 1494 / 322 22k 145–170 17–40 ms 10 ms (103 fps)
city t1l6m003 (largest WAD) 2177 / 439 41k 120–140 26–50 ms 18 ms (55 fps)

(Ranges: several agents shared the CPU.) Scene conversion from WAD 160–290 ms. On any real GPU this is far below 1 ms of GPU time; the CPU side is ~150 draw calls per frame.

GPU resources (2026-11-07)

  • GlRenderer.dispose() frees everything it created: batches, texture array, pick FBO, programs and the dynamic dynVao/dynVbo (gizmo, overlays).
  • Chunks that lose their last member are deleted with their batch (before: an empty chunk kept a Chunk entry until the next full rebuild). Shared per-mesh batches (meshBatches, objects and tiles without vertex colours) whose mesh no instance uses any more are freed once there are more than 256 (limit doubles with the live count) – replaced meshes used to stay until a level switch.
  • Viewport.liveGlBuffers() counts live VAO/VBO pairs of all batches (diagnostics). ui.view.GlLeakCheck (test sources, X display): 3 rounds over Village 1 / world 4 / Village 2, each load followed by 60 rounds of tile moves, object moves, delete + undo while rendering → the count is identical per level in every round (84 / 87 / 76), 0 with no level loaded.
  • SceneRenderer drops the pick ids of nodes that are no longer in the scene every 256 removals (nodes that come back by undo get a new id; selected nodes keep theirs) – SceneRendererIdsTest. Tiles marked "no collision" use a copy of their model mesh without the collision overlay.

Known limits / next steps

  • Only the first vertex colour frame and the primary tile model (no alt/swap models).
  • Translucent faces are not depth-sorted (as the game does per batch; artefacts are rare).
  • Engine blend table only approximated; no texture animations (TEXT nAnim block), no sky/backdrop geometry, no lights (LGHT) yet.
  • Characters: frame 0 of a guessed animation set; models whose pose fails show a diamond.
  • Collision of object models (moving platforms, doors) is not drawn (same as the debug mod).
  • Viewport.dispose() plus disposing the frame can hang on Xvfb without a window manager (AWT focus round trip); the demo exits with System.exit after a shot.

Screenshots: img/editor/01_village.jpg … 12_viewer_ui.jpg (village/river/city/Testwelt with and without collision, ortho top views, translate/rotate gizmo, the demo window with a selection).