22 KiB
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.
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). Nativesnatives-linuxandnatives-windowsoflwjglandlwjgl-openglare runtime dependencies, so the fat jar (and with it both packages) carrieslinux/x64/…/*.soandwindows/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.classfiles are excluded. jlink modules +jdk.unsupported(LWJGL/JOML usesun.misc.Unsafe).packageLinux/packageWindowsotherwise 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 jlinkedLlamaMod-linux-x64runtime – 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 --modsstill works. Real Windows hardware not tested.
- Linux native (Xvfb + llvmpipe,
-Dllamamod.editor.msaa=0disables 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 fixed0.15 × distancedolly 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.viewMatrixnow 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 inNavigationandViewCube(pure,NavigationTest,ViewCubeTest). Plugintools.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).
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-spaceOverlayper 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). Optionaloverlay= local-space helper geometry that follows every instance (collision).RenderInstance(id, kind, mesh):transform(JOMLMatrix4f),vertexColors(game convention 0x80 = 1.0, doubled with clamp),tint,visible,layer. KindTILEinstances are baked into static batches;OBJECT/PREVIEWare 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;
PickIdis 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 withglFrontFace(GL_CW); data, picking (ray/projectuse the matrices) and the scene model are unchanged. Camera.yaw/pitchequal 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
FogOverridewithclip+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): witharoundPivotthe 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:
Cullingboxes 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=truedisables 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 vsnoCull: 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 asGeometry.TileXform), vertex colours = first colour frame (alt models of WFPC 0x10000 tiles are not shown). UVs viaGeometry.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–StratModelsscans 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 by0xb2 <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) becomesscene.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 dynamicdynVao/dynVbo(gizmo, overlays).- Chunks that lose their last member are deleted with their batch (before: an empty chunk kept a
Chunkentry 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.SceneRendererdrops 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 withSystem.exitafter 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).

