# 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](img/editor/00_montage.jpg) ## 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 \ [--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](img/editor/cull_nav_prefs.jpg) ## API ```java 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. * 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 ; 0x54` – `StratModels` scans the strat from its entry to the next strat's name site and takes the first such pair; spawn stubs (`b2 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 ` 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).