editor: tile paint and collision strokes are one undo step, no collision option for tiles (visual-only model copy in the build), freed gl buffers and stale pick ids, export level as gltf/glb with textures and vertex colours

This commit is contained in:
MineTec committed 2026-11-07 11:57:22 +01:00
1 parent 59531a60b8
commit 2f22a56cec
30 files changed
+1737 -27

No files matched your search

+11 -1
View File
@@ -267,6 +267,8 @@ used in the level** hides unused ones. Click a model to make it the current one,
Painting onto a tile at the same height **swaps its model** instead of stacking a second tile.
New tiles copy the vertex colours of an existing tile with the same model.
One drag is one undo step: **Ctrl+Z** brings back all tiles a stroke painted over, erased or swapped.
> **Tip:** Prefer swapping, moving or hiding original tiles over erasing them. Object scripts can
> address tiles by their number, and erasing a tile shifts the numbers of all later tiles. The
> status bar warns you when you erase an original tile.
@@ -326,7 +328,15 @@ change all tiles** (paint the model, every tile using it changes) or **Shared mo
this tile** (the painted tile gets its own copy of the model first).
Brushes have one material per face class (top, sides, bottom); tile models are painted per
polygon. Tile collision cannot be switched off – paint it 30 or hide the tile instead.
polygon. A painting drag is one undo step.
### Tiles without collision
To make a tile only visual, select it and tick **No collision** (Inspector, section **Build**; works
for several selected tiles at once). The built level then uses a copy of the tile's model without
collision polygons: the llama walks through the tile (a wall) or falls through it (a floor). Other
tiles of the same model keep their collision. In the editor the tile no longer shows collision
lines, and checks such as "no floor under the player start" ignore it.
The layer **Invisible collision volumes** draws invisible walls, invisible blocks and colliders
translucent red so you can see them while editing.
+23 -1
View File
@@ -7,7 +7,7 @@ collision and lit vertex colours, and its textures are packed onto texture pages
The **library** works the other way round: it saves geometry from any level (original tiles,
brushes and meshes, with their textures) so you can insert it into other levels. And **Export
selection as OBJ** takes level geometry to Blender.
selection as OBJ** takes level geometry to Blender, **Export level as glTF** the whole level.
## Supported files
@@ -178,6 +178,28 @@ it in Blender, then **File → Import mesh** it back (round trip).
> **Note:** The exported textures are game data – fine for your own editing, please don't share
> them.
## Export the level as glTF
**File → Export level as glTF…** writes the whole level – tiles, brushes and meshes – as one glTF
2.0 file for Blender or any 3D viewer:
* **.glb** (binary) or **.gltf** (text, everything in one file), chosen by the file type,
* the level's texture pages as PNG pictures inside the file, the game's vertex colours
(light and shade) as vertex colours, transparent and double-sided parts as such,
* unlit materials, so viewers show the baked colours like the game.
| Option | Meaning |
|---|---|
| **Object models** | also the models of placed objects (coins, characters …) as the viewport shows them |
| **Leave out hidden items** | items hidden in the editor (**H**) are not exported |
| **Game orientation** | mirrors the level so that Blender and viewers show it the same way round as the game. Off: the same axes as the OBJ export and **Import mesh**, so the file can be imported again unchanged |
Tiles with **Exclude from build** are left out. 1 unit is 1 cell.
![glTF export dialog and the exported village read back by Import mesh](img/geo_gltf_export.jpg)
> **Note:** The exported textures are game data – fine for your own use, please don't share them.
## Tips for good meshes
* Model at the right scale: 1 cell is one grid square, the llama is about 0.6 cells tall. Use
+1
View File
@@ -157,6 +157,7 @@ Every menu command with its shortcut and a short description.
| Import texture… | | Import PNG/JPG images as textures (colour reduction, blend class, packing) |
| Export level WAD… | | Build the level and save the game file (.wad) somewhere |
| Export selection as OBJ… | | Mesh, UVs, vertex colours, textures (PNG) and collision for Blender |
| Export level as glTF… | | The whole level with textures and vertex colours as .glb / .gltf (Blender, viewers) |
| Close editor | **Ctrl+Q** | Close the editor |
### Edit menu
Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+7 -5
View File
@@ -45,11 +45,13 @@ undo/history, project IO, build, play-test; property/fuzz tests; stress run of t
## Not fixed (reasons)
* Damaged-file dialogs show raw exception text ("ClassCastException …"); cosmetic, left to the UX review.
* After a failed open, the status bar keeps "Loading <file>" (`StatusBar.progressDone` keeps the last message) – cosmetic (UX).
* `GlRenderer.dispose()` does not delete `dynVao`/`dynVbo` (only at window close) – negligible.
* `SceneRenderer.ids`/`nodes` keep pick ids of deleted nodes until the next level (small, bounded by edits).
* Tile-paint erase strokes are one undo step per tile (`DeferredRemove` has no merge) – UX, not a defect.
* ~~Damaged-file dialogs show raw exception text~~ – fixed 2026-11-07 (edui): `ErrorDialog` (all `ctx.error` calls) shows one
plain sentence ("The project file is damaged.", "The file x does not exist.") and the exception + stack trace behind **Details**.
* ~~After a failed open, the status bar keeps "Loading <file>"~~ – fixed 2026-11-07 (edui): a failed background task sets
"<task> failed"; EditorStress `corrupt` now fails if the status still says "Loading…".
* ~~`GlRenderer.dispose()` does not delete `dynVao`/`dynVbo`~~ – fixed 2026-11-07 (edgeo), see editor-render.md.
* ~~`SceneRenderer.ids`/`nodes` keep pick ids of deleted nodes~~ – fixed 2026-11-07 (edgeo): dropped every 256 removals.
* ~~Tile-paint erase strokes are one undo step per tile~~ – fixed 2026-11-07 (edgeo): `cmd.Stroke`, one step per drag.
* `installTarget` treats a file with the same kind/base/level name as the previous play-test copy; two different
projects with the same level name and base still share one file (rare; renaming one avoids it).
* `LevelProjectSource`: when all TestLevel chapters 2..49 are taken, the 49th is used twice – practically unreachable.
+16
View File
@@ -277,6 +277,22 @@ behind: game look / editor).
(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).
+50 -2
View File
@@ -62,6 +62,20 @@ height Page Up/Down. Painting onto a tile at the same height swaps its model ins
copy the vertex colours of a tile with the same model. **Tile swaps** (doors/bridges, WFPC 0x10000):
the Tile section shows the swap entry, sets the alternative model from the palette, removes or adds
a swap; layer "Tile swap alternatives" draws the alternative model in cyan.
One drag is **one undo step** (2026-11-07): paint, erase and swap issue `cmd.Stroke` commands with
the gesture's key; `Stroke.mergeWith` concatenates them (a `Batch` only merges commands of the same
shape, `DeferredRemove`/`AddNode` never merge). The collision tool's paint/toggle strokes use the
same command.
**No collision (visual only)** (2026-11-07): tile property `noCollision` (Inspector → Build, "No
collision"). `WadBuilder.visualOnly` replaces such tiles in the build by copies whose model (and
swap models) is a copy without collision polygons (`gen:visual:<model id>`, one per model, appended
to TRAK); other tiles of the model keep it. `Tile.worldCollisionPolygons()` is empty for them, so
`CollisionIndex`, checks, `FloorFinder`, `Ground` and the death-plane coverage ignore them; the
renderer draws them without the collision overlay. Default off = byte-identical builds.
In game (apptest :23, `NoCollisionDemo`, TestLevel 6): two walls baked from box brushes into scene
tiles, the north one visual only – the llama walks through it, the south one stops it
(`img/editor/geo_nocollision_game.jpg`, top row through, bottom row blocked).
### Collision tool (K)
Turns the collision layer on. Hovering highlights the polygon (or, on brushes, the whole face) under
@@ -100,6 +114,33 @@ and keeps the placement.
(round trip verified: same shape and textures, `img/editor/geo_library_insert.jpg` – right the
library tiles, left the re-imported OBJ as a mesh brush).
### glTF export (File → Export level as glTF…, `editor.tools.gltf`)
* `GltfExport.collect(scene, Options(objects, gameView), skip)` runs a private `SceneRenderer` on the
scene and takes exactly what the viewport draws: tile instances (scene tiles + compiled brushes and
meshes), optionally object models; `excluded` tiles and (option) hidden nodes are skipped.
Vertices are baked to world (export) space, deduplicated per primitive; one primitive per
page × blend class × double-sided, grouped into the meshes/nodes `level` and `objects`.
* Attributes: POSITION (exact min/max), NORMAL (unit; zero vertex normals → face normal; degenerate
triangles dropped), TEXCOORD_0 (page-normalised, v = 0 at the top as in glTF), COLOR_0 (u8
normalised RGBA = engine vertex colour ×2 clamped × flat/translucent face colour, as the viewport
shader). Materials: base colour texture = page PNG (embedded bufferView), `MASK` 0.5 for pages
with cut-out pixels, `BLEND` for alpha/additive classes (glTF has no additive), `doubleSided`,
`KHR_materials_unlit` (optional extension). u16 indices up to 65535 vertices, else u32.
* `.glb` (JSON + BIN chunk) or `.gltf` (one file, buffer as base64 data URI).
* Axes: export space (y up, z = −game z) like the OBJ export and the mesh importer, so a round trip
through **Import mesh** is exact. **Game orientation** mirrors z (and winding) for viewers – see
the open handedness question below.
* Verified: `GltfExportTest` (structural validation as the Khronos validator would: GLB header and
chunk padding, view/accessor bounds, equal attribute counts, exact POSITION min/max, unit normals,
UV range, index range, PNGs decode; .gltf data URI; round trip through `MeshImport` with the same
triangle count, bounds, textures and colours; mirrored bounds; objects; excluded tiles; brush box).
The official validator (npm) could not be run here (external code not allowed). **Blender 2.9
(local install)** imports the village .glb without complaint: meshes `level` 26 355 tris /
`objects` 9 684, 16 + 15 materials, 14 images, vertex colours and UVs
(`img/editor/geo_gltf_blender.jpg`, Workbench render); the editor's own Import mesh reads it back
(26 403 triangles, 16 materials, 15 textured, `img/editor/geo_gltf_export.jpg`).
Village: 36 087 triangles with objects, 2.9 MB .glb; without objects 2.1 MB.
### Checks (Level → Check, Build)
Tiles outside the grid; tile models (also compiled brush/mesh tiles) over **8000 vertices**;
**overlapping** identical tiles; **no floor collision under the player start** (cell-based like the
@@ -156,9 +197,16 @@ between neighbouring floors at the same height; brush compiler warnings.
## Known limits
* Collision materials of brushes are per face class, not per individual polygon; tile models per
polygon. Collision of tile-model polygons cannot be switched off (paint 30 or hide the tile).
polygon. Collision of a whole tile can be switched off ("No collision"), single tile-model polygons cannot.
* Water and death planes are cell based: resize/move/90° only. Gaps check only unrotated
box/platform/floor tops.
* Library insert keeps tiles cell-aligned (anchor = whole cells); objects are not part of the library
(see the objects agent's prefabs / object catalogue). glTF export not done (OBJ only).
(see the objects agent's prefabs / object catalogue).
* glTF export: whole level only (no "selection" variant), no collision export (the OBJ export has it),
no texture animations, additive surfaces become plain alpha blending. **Handedness**: the editor
view is left-handed like the game (editor-render.md), but the mesh importer and the OBJ/glTF
exports treat file coordinates as export space – so a right-handed tool (Blender) shows exported
levels mirrored relative to the game, and an asymmetric model imported from Blender probably
appears mirrored in game. Not verified in game; "Game orientation" in the glTF export is the
workaround for viewing.
* `TextureImport` packs imported textures at one size per insert (largest texture, ≤ 256).
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

@@ -1,5 +1,7 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.RawChunk;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.TextureEntry;
@@ -9,7 +11,10 @@ import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.Wad;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
/**
* Scene → level WAD. Brushes and mesh placements are converted to tiles ({@link BrushCompiler})
@@ -48,6 +53,7 @@ public final class WadBuilder {
models.addAll(gen.models());
List<Tile> tiles =
new ArrayList<>(s.tiles().stream().filter(t -> !t.excluded()).toList());
visualOnly(tiles, models);
tiles.addAll(gen.tiles());
List<TexturePage> pages = new ArrayList<>(s.pages());
pages.addAll(gen.pages());
@@ -92,6 +98,46 @@ public final class WadBuilder {
return new Result(wad, st, warn);
}
/**
* Tiles marked "no collision" get a copy of their model (and swap models) without collision
* polygons, appended to models; one copy per model.
*/
static void visualOnly(List<Tile> tiles, List<TileModel> models) {
Map<TileModel, TileModel> copies = new IdentityHashMap<>();
Function<TileModel, TileModel> strip = m -> m == null
? null
: copies.computeIfAbsent(m, k -> {
TileModel c = copy(new TileModel("gen:visual:" + k.id()), k);
for (int g = 0; g < 3; g++) {
c.setRaw(TileModel.collisionProp(g), new byte[0]);
}
models.add(c);
return c;
});
for (int i = 0; i < tiles.size(); i++) {
Tile t = tiles.get(i);
if (t.noCollision() && t.model() != null) {
Tile c = copy(new Tile(t.id()), t);
c.setRaw("model", strip.apply(t.model()));
if (t.hasSwap()) {
c.setRaw("swapModel", strip.apply(t.swapModel()));
c.setRaw("swapAltModel", strip.apply(t.swapAltModel()));
}
tiles.set(i, c);
}
}
}
/** Copies every property in declaration order (later raw values such as headers survive setters). */
private static <N extends Node> N copy(N to, N from) {
for (Prop p : from.props()) {
if (p.settable()) {
to.setRaw(p.name(), from.get(p.name()));
}
}
return to;
}
private static byte[] name(Scene s, RawChunk c) {
String n = s.settings().levelName();
if (c.managed()) {
@@ -6,8 +6,8 @@ import java.util.List;
/**
* Placed tile (MAP): a {@link TileModel} at (x, y, z) in export space (cells; z ≤ 0, game z = −z)
* turned by {@code rot}/4096 turns about y. The tile belongs to grid cell (floor(x), floor(−z)); the
* engine tests collision only against the tiles of the player's cell.
* turned by {@code rot}/4096 turns about y. The tile belongs to the grid cell of its origin, see
* {@link #cellX()}; the engine tests collision only against the tiles of the player's cell.
*
* <p>Vertex colours: {@code colors} holds frames × vertexCount(model) RGBA words (0x80 = 1.0, the
* engine doubles them); byte 3 of the first word is the frame count (0/1 = one frame). A tile with
@@ -30,6 +30,7 @@ public final class Tile extends Node {
TileModel swapAltModel;
int[] altColors;
boolean excluded;
boolean noCollision;
private static final List<Prop> PROPS = new Props<Tile>()
.group("Transform")
@@ -86,6 +87,14 @@ public final class Tile extends Node {
b -> b.desc(
"the tile stays in the project but is left out of the built level (Hide only affects the"
+ " editor view)"))
.p(
"noCollision",
"No collision",
PropType.BOOL,
n -> n.noCollision,
(n, v) -> n.noCollision = v,
b -> b.desc("the built level gets this tile without collision polygons: the llama walks"
+ " through it"))
.group("Colours")
.ints("colors", "Vertex colours (frames × vertices)", n -> n.colors, (n, v) -> n.colors = v)
.p(
@@ -131,6 +140,11 @@ public final class Tile extends Node {
return excluded;
}
/** True when the tile is only visual: the build writes it with a model copy without collision. */
public boolean noCollision() {
return noCollision;
}
public double x() {
return x;
}
@@ -194,14 +208,18 @@ public final class Tile extends Node {
|| swapAltModel != null && swapAltModel.hasCollisionMaterial(material));
}
/** Grid column, like MAP's cell lists (floor(x)). */
/**
* Grid column of the MAP cell list the engine puts the tile in: the loader (FUN_0042ac50) takes
* ftol(x − 0.5), truncating; = floor(x) for the usual cell-centred tiles (x = n + 0.5), one cell
* less for a tile whose origin is in the left half of a cell.
*/
public int cellX() {
return (int) Math.floor(x);
return (int) (x - 0.5);
}
/** Grid row (floor(−z)). */
/** Grid row: −ftol(z + 0.5) (export z ≤ 0); = floor(−z) for cell-centred tiles. */
public int cellZ() {
return (int) Math.floor(-z);
return -(int) (z + 0.5);
}
/** Fixed-point placement the engine uses: stored value or derived from x, y, z, rot. */
@@ -220,9 +238,9 @@ public final class Tile extends Node {
};
}
/** Collision polygons of the model in world export space (none without a model). */
/** Collision polygons of the model in world export space (none without a model or with {@link #noCollision}). */
public List<TileModel.PlacedPolygon> worldCollisionPolygons() {
return model == null ? List.of() : model.collisionPolygons(transform());
return model == null || noCollision ? List.of() : model.collisionPolygons(transform());
}
/** Local (export space) vertex → world (export space). */
@@ -0,0 +1,64 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.util.ArrayList;
import java.util.List;
/**
* One step of a paint/erase stroke: consecutive strokes with the same key are concatenated into
* one undo step (unlike {@link Batch}, whose merge needs commands of the same shape).
*/
public final class Stroke implements Command {
private final String label;
private final List<Command> commands;
private final Object key;
public Stroke(String label, Command c, Object key) {
this(label, List.of(c), key);
}
private Stroke(String label, List<Command> commands, Object key) {
this.label = label;
this.commands = commands;
this.key = key;
}
public List<Command> commands() {
return commands;
}
@Override
public String label() {
return label;
}
@Override
public void apply(Scene s) {
for (Command c : commands) {
c.apply(s);
}
}
@Override
public void revert(Scene s) {
for (int i = commands.size() - 1; i >= 0; i--) {
commands.get(i).revert(s);
}
}
@Override
public Object mergeKey() {
return key;
}
@Override
public Command mergeWith(Command next) {
if (!(next instanceof Stroke o) || key == null || !key.equals(o.key)) {
return null;
}
List<Command> l = new ArrayList<>(commands.size() + o.commands.size());
l.addAll(commands);
l.addAll(o.commands);
return new Stroke(label, l, key);
}
}
@@ -15,6 +15,8 @@ final class Batch {
static final int STRIDE = 36;
static final int LINE_STRIDE = 16;
static final int RANGES = 6;
/** Live VAO/VBO pairs of all batches (leak diagnostics, {@link Viewport#liveGlBuffers()}). */
static final java.util.concurrent.atomic.AtomicInteger LIVE = new java.util.concurrent.atomic.AtomicInteger();
int vao;
int vbo;
@@ -36,10 +38,12 @@ final class Batch {
if (vao != 0) {
glDeleteVertexArrays(vao);
glDeleteBuffers(vbo);
LIVE.decrementAndGet();
}
if (lineVao != 0) {
glDeleteVertexArrays(lineVao);
glDeleteBuffers(lineVbo);
LIVE.decrementAndGet();
}
vao = vbo = lineVao = lineVbo = 0;
}
@@ -194,6 +198,7 @@ final class Batch {
all.flip();
g.vao = glGenVertexArrays();
g.vbo = glGenBuffers();
LIVE.incrementAndGet();
glBindVertexArray(g.vao);
glBindBuffer(GL_ARRAY_BUFFER, g.vbo);
glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW);
@@ -207,6 +212,7 @@ final class Batch {
all.put(lines.b.flip()).put(fill.b.flip()).flip();
g.lineVao = glGenVertexArrays();
g.lineVbo = glGenBuffers();
LIVE.incrementAndGet();
glBindVertexArray(g.lineVao);
glBindBuffer(GL_ARRAY_BUFFER, g.lineVbo);
glBufferData(GL_ARRAY_BUFFER, all, GL_STATIC_DRAW);
@@ -42,6 +42,8 @@ final class GlRenderer {
private final Map<RenderInstance, Batch> singles = new IdentityHashMap<>();
private final Map<RenderMesh, Batch> meshBatches = new IdentityHashMap<>();
private static final int MESH_BATCH_LIMIT = 256;
private int meshBatchLimit = MESH_BATCH_LIMIT;
private final Set<RenderInstance> detached = new HashSet<>();
private Batch markerBatch;
private Set<String> markerLayers = Set.of();
@@ -161,6 +163,12 @@ final class GlRenderer {
Chunk c = chunks.get(old);
c.members.remove(i);
c.dirty = true;
if (c.members.isEmpty()) {
if (c.batch != null) {
c.batch.delete();
}
chunks.remove(old);
}
}
if (batched(i) && scene.instances.contains(i)) {
long k = key(i);
@@ -183,6 +191,9 @@ final class GlRenderer {
}
sceneVersion = scene.version();
Set<RenderInstance> dirty = scene.takeDirty();
if (dirty != null && meshBatches.size() > meshBatchLimit) {
pruneMeshBatches();
}
if (dirty == null || chunks.isEmpty() && chunkOf.isEmpty()) {
Set<RenderInstance> keep = new HashSet<>(detached);
clearAll();
@@ -213,6 +224,23 @@ final class GlRenderer {
overlayBatches.clear();
}
/** Frees shared mesh batches whose mesh no instance of the scene uses any more (replaced meshes). */
private void pruneMeshBatches() {
Set<RenderMesh> used = java.util.Collections.newSetFromMap(new IdentityHashMap<>());
for (RenderInstance i : scene.instances) {
used.add(i.mesh);
}
meshBatches.entrySet().removeIf(e -> {
if (used.contains(e.getKey())) {
return false;
}
singles.values().removeIf(b -> b == e.getValue());
e.getValue().delete();
return true;
});
meshBatchLimit = Math.max(MESH_BATCH_LIMIT, meshBatches.size() * 2);
}
private Batch.Builder builder() {
return new Batch.Builder(whiteLayer, pageW, arraySize);
}
@@ -1015,6 +1043,12 @@ final class GlRenderer {
glDeleteRenderbuffers(pickColor);
glDeleteRenderbuffers(pickDepth);
}
if (dynVao != 0) {
glDeleteVertexArrays(dynVao);
glDeleteBuffers(dynVbo);
dynVao = dynVbo = 0;
dynCapacity = 0;
}
glDeleteProgram(meshProg);
glDeleteProgram(lineProg);
}
@@ -109,6 +109,7 @@ public final class Viewport extends JPanel {
private Overlay toolOverlay;
private Overlay toolTopOverlay;
private Overlay screenOverlay;
private volatile Overlay hudOverlay;
public Viewport() {
super(new BorderLayout());
@@ -184,6 +185,15 @@ public final class Viewport extends JPanel {
requestRender();
}
/**
* A second pixel overlay like {@link #setScreenOverlay}, for indicators that are independent of
* the active tool (e.g. a crosshair while a key is held). Null = none.
*/
public void setHudOverlay(Overlay pixels) {
hudOverlay = pixels;
requestRender();
}
/** The GL canvas (keyboard focus owner while the viewport is active; drop target). */
public Component canvas() {
return canvas;
@@ -390,6 +400,11 @@ public final class Viewport extends JPanel {
return new GlRendererStats(s.drawCalls(), s.triangles(), s.chunksDrawn(), s.chunksTotal(), s.cpuMs(), avg);
}
/** Live vertex array/buffer pairs of the renderer's batches (leak diagnostics). */
public static int liveGlBuffers() {
return Batch.LIVE.get();
}
/** Frame statistics: last frame's draw calls/triangles/chunks, CPU time and the average frame time. */
public record GlRendererStats(
int drawCalls, int triangles, int chunksDrawn, int chunksTotal, double cpuMs, double avgFrameMs) {}
@@ -746,6 +761,10 @@ public final class Viewport extends JPanel {
if (px != null && (px.lineCount() > 0 || px.triangleCount() > 0)) {
fp.toolTop = screenToWorld(px, toolTopOverlay);
}
Overlay hud = hudOverlay;
if (hud != null && (hud.lineCount() > 0 || hud.triangleCount() > 0)) {
fp.toolTop = screenToWorld(hud, fp.toolTop);
}
if (gizmo.visible()) {
fp.gizmoLines = new Overlay();
fp.gizmoTris = new Overlay();
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.model.cmd.Stroke;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.render.Overlay;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
@@ -233,7 +234,7 @@ final class CollisionTool implements Tool {
}
if (!b.brushKind().transformable()) {
if (mode == Mode.PAINT && BrushMath.has(b, "mat") && b.i("mat") != material) {
ctx.execute(SetProp.of(b, b.prop("mat"), material, stroke));
ctx.execute(new Stroke("Paint collision " + material, SetProp.of(b, "mat", material), stroke));
}
return;
}
@@ -247,7 +248,7 @@ final class CollisionTool implements Tool {
String cf = b.s("collFaces");
char c = BrushKind.FACES.charAt(k);
String n = cf.indexOf(c) >= 0 ? cf.replace(String.valueOf(c), "") : BrushMath.mapFaces(cf + c, "", "");
ctx.execute(SetProp.of(b, b.prop("collFaces"), n, stroke));
ctx.execute(new Stroke("Toggle collision", SetProp.of(b, "collFaces", n), stroke));
ctx.status(b.label() + ": " + FACE_NAMES[k] + " face collision " + (cf.indexOf(c) >= 0 ? "off" : "on"));
return;
}
@@ -260,7 +261,7 @@ final class CollisionTool implements Tool {
return;
}
fm[k] = want;
ctx.execute(SetProp.of(b, b.prop("faceMats"), fm, stroke));
ctx.execute(new Stroke("Paint collision " + material, SetProp.of(b, "faceMats", fm), stroke));
ctx.status(b.label() + ": " + FACE_NAMES[k] + " face → " + GeoWidgets.materialName(material));
return;
}
@@ -299,7 +300,7 @@ final class CollisionTool implements Tool {
target,
TileModel.collisionProp(p.group()),
target.withCollisionMaterial(p.group(), p.index(), material)));
ctx.execute(new Batch("Paint collision " + material, cmds, stroke));
ctx.execute(new Stroke("Paint collision " + material, new Batch("Paint collision " + material, cmds), stroke));
ctx.status(t.label() + " polygon " + p.index() + " → " + GeoWidgets.materialName(material)
+ (shared > 1 && !copyShared ? " (all " + shared + " tiles of this model)" : ""));
}
@@ -5,6 +5,7 @@ import com.eliasmueller.llamamod.editor.model.TextureEntry;
import com.eliasmueller.llamamod.editor.model.TexturePage;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.Widgets;
import com.eliasmueller.llamamod.levelbuild.LevelBuilder;
import com.eliasmueller.llamamod.util.SwingListeners;
import java.awt.BorderLayout;
@@ -392,7 +393,7 @@ final class GeoWidgets {
}
static JLabel hint(String html) {
JLabel l = new JLabel("<html><div style='width:215px'>" + html + "</div></html>");
JLabel l = Widgets.wrappingLabel(html);
l.setFont(l.getFont().deriveFont(11.5f));
l.setForeground(javax.swing.UIManager.getColor("Label.disabledForeground"));
return l;
@@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.SetProp;
import com.eliasmueller.llamamod.editor.model.cmd.Stroke;
import com.eliasmueller.llamamod.editor.render.Legend;
import com.eliasmueller.llamamod.editor.render.Overlay;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
@@ -225,9 +226,13 @@ final class TilePaintTool implements Tool {
&& t.rot() == rot * 1024) {
if (t.model() != model && !ctx.isLocked(t)) {
// a tile at this height: swap its model (keeps the tile's index for strat scripts)
ctx.execute(new Batch(
ctx.execute(new Stroke(
"Paint tiles",
List.of(SetProp.of(t, "model", model), SetProp.of(t, "colors", colours(s, model))),
new Batch(
"Paint tiles",
List.of(
SetProp.of(t, "model", model),
SetProp.of(t, "colors", colours(s, model)))),
stroke));
}
return;
@@ -241,7 +246,7 @@ final class TilePaintTool implements Tool {
t.setRaw("rot", rot * 1024);
t.setRaw("model", model);
t.setRaw("colors", colours(s, model));
ctx.execute(new Batch("Paint tiles", List.<Command>of(new AddNode(t)), stroke));
ctx.execute(new Stroke("Paint tiles", new AddNode(t), stroke));
ctx.status("Painted " + model.id() + " in cell " + key);
}
@@ -277,7 +282,7 @@ final class TilePaintTool implements Tool {
case ERASE -> {
if (t != null && !ctx.isLocked(t) && !painted.contains(t.id())) {
painted.add(t.id());
ctx.execute(new Batch("Erase tiles", List.of(Edits.delete(ctx.scene(), List.of(t))), stroke));
ctx.execute(new Stroke("Erase tiles", Edits.delete(ctx.scene(), List.of(t)), stroke));
ctx.status(
"Erased " + t.label()
+ (t.id().contains(":n")
@@ -303,7 +308,7 @@ final class TilePaintTool implements Tool {
List<Command> cmds = new ArrayList<>();
cmds.add(SetProp.of(t, "model", model));
cmds.add(SetProp.of(t, "colors", colours(ctx.scene(), model)));
ctx.execute(new Batch("Swap tile model", cmds, stroke));
ctx.execute(new Stroke("Swap tile model", new Batch("Swap tile model", cmds), stroke));
ctx.status(t.label() + " now uses " + model.id());
}
}
@@ -0,0 +1,111 @@
package com.eliasmueller.llamamod.editor.tools.gltf;
import com.eliasmueller.llamamod.editor.ui.ActionSpec;
import com.eliasmueller.llamamod.editor.ui.EditorContext;
import com.eliasmueller.llamamod.editor.ui.EditorExtensions;
import com.eliasmueller.llamamod.editor.ui.EditorPlugin;
import java.awt.BorderLayout;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JCheckBox;
import javax.swing.JFileChooser;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.filechooser.FileNameExtensionFilter;
/** File → Export level as glTF… (whole level: tiles, brushes, meshes, optionally objects). */
public final class GltfEditorPlugin implements EditorPlugin {
@Override
public void install(EditorExtensions x) {
x.action(ActionSpec.of(
"file.exportGltf",
"File",
30,
45,
"Export level as glTF…",
"export",
null,
GltfEditorPlugin::export)
.enabledWhen(c -> c.document() != null)
.tip("The whole level with textures and vertex colours as .glb / .gltf (Blender, viewers)"));
}
static void export(EditorContext ctx) {
if (ctx.scene() == null) {
return;
}
JFileChooser fc = new JFileChooser(ctx.prefs().getString("gltf.exportDir", System.getProperty("user.home")));
FileNameExtensionFilter glb = new FileNameExtensionFilter("glTF binary (.glb)", "glb");
FileNameExtensionFilter gltf = new FileNameExtensionFilter("glTF JSON, one file (.gltf)", "gltf");
fc.addChoosableFileFilter(glb);
fc.addChoosableFileFilter(gltf);
fc.setAcceptAllFileFilterUsed(false);
fc.setFileFilter(glb);
String stem = ctx.document().title().replaceAll("[^A-Za-z0-9._-]+", "_");
fc.setSelectedFile(new File((stem.isBlank() ? "level" : stem) + ".glb"));
JCheckBox objects = new JCheckBox("Object models", ctx.prefs().getBool("gltf.objects", false));
JCheckBox hidden = new JCheckBox("Leave out hidden items", ctx.prefs().getBool("gltf.skipHidden", true));
JCheckBox mirror = new JCheckBox("Game orientation", ctx.prefs().getBool("gltf.gameView", false));
mirror.setToolTipText("<html>Mirror z so that Blender and viewers show the level as the game does.<br>"
+ "Off: the same axes as the OBJ export and the editor's mesh import.</html>");
JPanel boxes = new JPanel();
boxes.setLayout(new BoxLayout(boxes, BoxLayout.Y_AXIS));
boxes.add(objects);
boxes.add(hidden);
boxes.add(mirror);
JPanel acc = new JPanel(new BorderLayout(0, 6));
acc.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
acc.add(boxes, BorderLayout.NORTH);
acc.add(
new JLabel("<html><div style='width:150px'><small>The textures are game data:"
+ " fine for your own use, please don't share them.</small></div></html>"),
BorderLayout.CENTER);
fc.setAccessory(acc);
if (fc.showSaveDialog(ctx.frame()) != JFileChooser.APPROVE_OPTION) {
return;
}
Path f = fc.getSelectedFile().toPath();
String lower = f.getFileName().toString().toLowerCase(Locale.ROOT);
if (!lower.endsWith(".glb") && !lower.endsWith(".gltf")) {
f = f.resolveSibling(f.getFileName() + (fc.getFileFilter() == gltf ? ".gltf" : ".glb"));
}
boolean binary = !f.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".gltf");
ctx.prefs().put("gltf.exportDir", f.getParent().toString());
ctx.prefs().put("gltf.objects", objects.isSelected());
ctx.prefs().put("gltf.skipHidden", hidden.isSelected());
ctx.prefs().put("gltf.gameView", mirror.isSelected());
GltfExport.Geometry g;
try {
g = GltfExport.collect(
ctx.scene(),
new GltfExport.Options(objects.isSelected(), mirror.isSelected()),
n -> hidden.isSelected() && ctx.isHidden(n));
} catch (RuntimeException e) {
ctx.error("Export glTF", e);
return;
}
Path out = f;
ctx.runTask(
"Exporting " + f.getFileName(),
p -> {
Files.write(out, binary ? g.glb() : g.gltf());
return Files.size(out);
},
r -> {
if (r instanceof Long size) {
ctx.status(String.format(
Locale.ROOT,
"Exported %,d triangles (%d materials) to %s (%.1f MB) – textures are game data:"
+ " personal use only",
g.triangles(),
g.primitives(),
out.getFileName(),
size / 1e6));
}
});
}
}
@@ -0,0 +1,465 @@
package com.eliasmueller.llamamod.editor.tools.gltf;
import com.eliasmueller.llamamod.editor.io.Images;
import com.eliasmueller.llamamod.editor.model.Node;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.render.RenderInstance;
import com.eliasmueller.llamamod.editor.render.RenderMesh;
import com.eliasmueller.llamamod.editor.render.RenderScene;
import com.eliasmueller.llamamod.editor.render.TexturePage;
import com.eliasmueller.llamamod.editor.ui.view.SceneRenderer;
import com.eliasmueller.llamamod.platform.Json;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import org.joml.Matrix3f;
import org.joml.Vector3f;
/**
* Level geometry (tiles, compiled brushes and meshes, optionally object models) as glTF 2.0: the
* triangles the editor viewport draws, baked to world space, one primitive per texture page × blend
* class × sidedness. Textures are the level's pages as PNG, the engine's vertex colours (doubled with
* clamp, times flat/translucent face colours) are COLOR_0, materials are unlit
* (KHR_materials_unlit, optional). Coordinates are export space like the OBJ export and the mesh
* importer (y up, z = −game z, 1 unit = 1 cell); {@link Options#gameView} mirrors z instead.
*
* <p>Two steps: {@link #collect} reads the scene (EDT), {@link Geometry#glb} / {@link Geometry#gltf}
* encode the snapshot (any thread).
*/
public final class GltfExport {
private GltfExport() {}
/**
* objects: also the models of placed objects (strats) as the viewport shows them; gameView:
* mirror z so that right-handed viewers show the level as the game does (re-importing such a
* file into the editor gives mirrored geometry).
*/
public record Options(boolean objects, boolean gameView) {}
/** One glTF primitive: unique vertices + triangle indices. */
static final class Prim {
final int page;
final int blend;
final boolean doubleSided;
final String group;
final List<float[]> verts = new ArrayList<>();
final List<Integer> cols = new ArrayList<>();
final Map<VKey, Integer> index = new HashMap<>();
final List<Integer> tris = new ArrayList<>();
Prim(String group, int page, int blend, boolean doubleSided) {
this.group = group;
this.page = page;
this.blend = blend;
this.doubleSided = doubleSided;
}
int vertex(float[] v, int rgba) {
return index.computeIfAbsent(new VKey(v, rgba), k -> {
verts.add(v);
cols.add(rgba);
return verts.size() - 1;
});
}
}
/** Position, normal, uv, rgba as one hashable key. */
private record VKey(float x, float y, float z, float nx, float ny, float nz, float u, float v, int c) {
VKey(float[] a, int c) {
this(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], c);
}
}
/** Snapshot of the exported geometry: primitives and the used texture pages. */
public static final class Geometry {
final List<Prim> prims;
final List<TexturePage> pages;
final String name;
Geometry(String name, List<Prim> prims, List<TexturePage> pages) {
this.name = name;
this.prims = prims;
this.pages = pages;
}
public int triangles() {
int n = 0;
for (Prim p : prims) {
n += p.tris.size() / 3;
}
return n;
}
public int vertices() {
int n = 0;
for (Prim p : prims) {
n += p.verts.size();
}
return n;
}
public int primitives() {
return prims.size();
}
/** Binary glTF (.glb). */
public byte[] glb() {
Encoded e = encode(false);
byte[] json = pad(e.json.getBytes(StandardCharsets.UTF_8), (byte) ' ');
byte[] bin = pad(e.bin, (byte) 0);
ByteBuffer b =
ByteBuffer.allocate(12 + 8 + json.length + 8 + bin.length).order(ByteOrder.LITTLE_ENDIAN);
b.putInt(0x46546C67).putInt(2).putInt(b.capacity());
b.putInt(json.length).putInt(0x4E4F534A).put(json);
b.putInt(bin.length).putInt(0x004E4942).put(bin);
return b.array();
}
/** JSON glTF (.gltf) with the buffer embedded as a data URI (one self-contained file). */
public byte[] gltf() {
return encode(true).json.getBytes(StandardCharsets.UTF_8);
}
private Encoded encode(boolean dataUri) {
Writer w = new Writer();
Map<Integer, Integer> imageOf = new HashMap<>();
List<Object> images = new ArrayList<>();
List<Object> textures = new ArrayList<>();
for (Prim p : prims) {
if (p.page >= 0 && !imageOf.containsKey(p.page)) {
int view = w.view(png(pages.get(p.page)), null);
imageOf.put(p.page, images.size());
images.add(map("name", "page" + p.page, "bufferView", view, "mimeType", "image/png"));
textures.add(map("sampler", 0, "source", images.size() - 1));
}
}
List<Object> materials = new ArrayList<>();
Map<String, Integer> matOf = new LinkedHashMap<>();
Map<String, List<Object>> meshPrims = new LinkedHashMap<>();
for (Prim p : prims) {
String mk = p.page + "/" + p.blend + "/" + p.doubleSided;
Integer mi = matOf.get(mk);
if (mi == null) {
mi = materials.size();
matOf.put(mk, mi);
materials.add(material(p, imageOf, pages));
}
Map<String, Object> attr = new LinkedHashMap<>();
int n = p.verts.size();
ByteBuffer pos = buf(n * 12);
ByteBuffer nor = buf(n * 12);
ByteBuffer uv = buf(n * 8);
ByteBuffer col = buf(n * 4);
float[] min = {Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE};
float[] max = {-Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE};
for (int vi = 0; vi < n; vi++) {
float[] v = p.verts.get(vi);
for (int k = 0; k < 3; k++) {
pos.putFloat(v[k]);
min[k] = Math.min(min[k], v[k]);
max[k] = Math.max(max[k], v[k]);
nor.putFloat(v[3 + k]);
}
uv.putFloat(v[6]).putFloat(v[7]);
int c = p.cols.get(vi);
col.put((byte) (c >>> 24))
.put((byte) (c >>> 16))
.put((byte) (c >>> 8))
.put((byte) c);
}
attr.put("POSITION", w.accessor(pos, 34962, 5126, n, "VEC3", false, min, max));
attr.put("NORMAL", w.accessor(nor, 34962, 5126, n, "VEC3", false, null, null));
if (p.page >= 0) {
attr.put("TEXCOORD_0", w.accessor(uv, 34962, 5126, n, "VEC2", false, null, null));
}
attr.put("COLOR_0", w.accessor(col, 34962, 5121, n, "VEC4", true, null, null));
boolean small = n <= 65535;
ByteBuffer idx = buf(p.tris.size() * (small ? 2 : 4));
for (int t : p.tris) {
if (small) {
idx.putShort((short) t);
} else {
idx.putInt(t);
}
}
int ia = w.accessor(idx, 34963, small ? 5123 : 5125, p.tris.size(), "SCALAR", false, null, null);
meshPrims
.computeIfAbsent(p.group, g -> new ArrayList<>())
.add(map("attributes", attr, "indices", ia, "material", mi, "mode", 4));
}
List<Object> meshes = new ArrayList<>();
List<Object> nodes = new ArrayList<>();
for (Map.Entry<String, List<Object>> e : meshPrims.entrySet()) {
nodes.add(map("name", e.getKey(), "mesh", meshes.size()));
meshes.add(map("name", e.getKey(), "primitives", e.getValue()));
}
List<Object> sceneNodes = new ArrayList<>();
for (int i = 0; i < nodes.size(); i++) {
sceneNodes.add(i);
}
byte[] bin = w.out.toByteArray();
Map<String, Object> buffer = new LinkedHashMap<>();
buffer.put("byteLength", bin.length);
if (dataUri) {
buffer.put(
"uri",
"data:application/octet-stream;base64,"
+ Base64.getEncoder().encodeToString(bin));
}
Map<String, Object> j = new LinkedHashMap<>();
j.put("asset", map("version", "2.0", "generator", "LlamaMod level editor"));
j.put("extensionsUsed", List.of("KHR_materials_unlit"));
j.put("scene", 0);
j.put("scenes", List.of(map("name", name, "nodes", sceneNodes)));
j.put("nodes", nodes);
j.put("meshes", meshes);
j.put("materials", materials);
if (!textures.isEmpty()) {
j.put("samplers", List.of(map("magFilter", 9729, "minFilter", 9729, "wrapS", 33071, "wrapT", 33071)));
j.put("textures", textures);
j.put("images", images);
}
j.put("accessors", w.accessors);
j.put("bufferViews", w.views);
j.put("buffers", List.of(buffer));
return new Encoded(Json.write(j), bin);
}
}
private record Encoded(String json, byte[] bin) {}
private static Map<String, Object> material(Prim p, Map<Integer, Integer> imageOf, List<TexturePage> pages) {
Map<String, Object> pbr = new LinkedHashMap<>();
pbr.put("baseColorFactor", List.of(1, 1, 1, 1));
if (p.page >= 0) {
pbr.put("baseColorTexture", map("index", imageOf.get(p.page)));
}
pbr.put("metallicFactor", 0);
pbr.put("roughnessFactor", 1);
Map<String, Object> m = new LinkedHashMap<>();
String blend = p.blend == RenderMesh.BLEND_ADD ? "add" : p.blend == RenderMesh.BLEND_ALPHA ? "alpha" : "opaque";
m.put("name", (p.page >= 0 ? "page" + p.page : "flat") + "_" + blend + (p.doubleSided ? "_2s" : ""));
m.put("pbrMetallicRoughness", pbr);
if (p.blend != RenderMesh.BLEND_OPAQUE) {
m.put("alphaMode", "BLEND");
} else if (p.page >= 0 && hasCutout(pages.get(p.page))) {
m.put("alphaMode", "MASK");
m.put("alphaCutoff", 0.5);
}
if (p.doubleSided) {
m.put("doubleSided", true);
}
m.put("extensions", map("KHR_materials_unlit", Map.of()));
return m;
}
private static boolean hasCutout(TexturePage p) {
for (int c : p.rgba()) {
if ((c & 0xff) < 128) {
return true;
}
}
return false;
}
private static byte[] png(TexturePage p) {
BufferedImage img = new BufferedImage(p.width(), p.height(), BufferedImage.TYPE_INT_ARGB);
int[] argb = new int[p.rgba().length];
for (int i = 0; i < argb.length; i++) {
int c = p.rgba()[i];
argb[i] = (c & 0xff) << 24 | c >>> 8;
}
img.setRGB(0, 0, p.width(), p.height(), argb, 0, p.width());
return Images.png(img);
}
private static ByteBuffer buf(int n) {
return ByteBuffer.allocate(n).order(ByteOrder.LITTLE_ENDIAN);
}
private static byte[] pad(byte[] b, byte fill) {
int n = (b.length + 3) & ~3;
if (n == b.length) {
return b;
}
byte[] o = java.util.Arrays.copyOf(b, n);
java.util.Arrays.fill(o, b.length, n, fill);
return o;
}
private static Map<String, Object> map(Object... kv) {
Map<String, Object> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) {
m.put((String) kv[i], kv[i + 1]);
}
return m;
}
/** Binary buffer, buffer views (4-byte aligned) and accessors. */
private static final class Writer {
final ByteArrayOutputStream out = new ByteArrayOutputStream();
final List<Object> views = new ArrayList<>();
final List<Object> accessors = new ArrayList<>();
int view(byte[] data, Integer target) {
while (out.size() % 4 != 0) {
out.write(0);
}
Map<String, Object> v = map("buffer", 0, "byteOffset", out.size(), "byteLength", data.length);
if (target != null) {
v.put("target", target);
}
out.writeBytes(data);
views.add(v);
return views.size() - 1;
}
int accessor(
ByteBuffer data,
int target,
int componentType,
int count,
String type,
boolean normalized,
float[] min,
float[] max) {
int v = view(data.array(), target);
Map<String, Object> a = map("bufferView", v, "componentType", componentType, "count", count, "type", type);
if (normalized) {
a.put("normalized", true);
}
if (min != null) {
a.put("min", List.of((double) min[0], (double) min[1], (double) min[2]));
a.put("max", List.of((double) max[0], (double) max[1], (double) max[2]));
}
accessors.add(a);
return accessors.size() - 1;
}
}
// ------------------------------------------------------------------ collect
/** Reads the scene as the viewport draws it; skip: nodes to leave out (e.g. hidden ones). */
public static Geometry collect(Scene s, Options o, Predicate<Node> skip) {
SceneRenderer sr = new SceneRenderer();
sr.setScene(s);
RenderScene rs = sr.renderScene();
Map<String, Prim> prims = new LinkedHashMap<>();
Vector3f p = new Vector3f();
Vector3f n = new Vector3f();
Matrix3f nm = new Matrix3f();
float[][] tri = new float[3][];
int[] rgba = new int[3];
Vector3f e1 = new Vector3f();
Vector3f e2 = new Vector3f();
for (RenderInstance ri : rs.instances) {
boolean object = ri.kind == RenderInstance.Kind.OBJECT;
if (ri.kind != RenderInstance.Kind.TILE && !(object && o.objects())) {
continue;
}
Node owner = sr.nodeOf(ri.id);
if (owner instanceof Tile t && t.excluded() || owner != null && skip.test(owner)) {
continue;
}
RenderMesh m = ri.mesh;
ri.transform.normal(nm);
String group = object ? "objects" : "level";
for (int t = 0; t < m.triangleCount(); t++) {
int fl = m.triFlags[t];
int page = m.pages[t] < rs.pages.size() ? m.pages[t] : -1;
int tc = m.triColors[t];
for (int k = 0; k < 3; k++) {
int v = m.triangles[3 * t + k];
ri.transform.transformPosition(
p.set(m.positions[3 * v], m.positions[3 * v + 1], m.positions[3 * v + 2]));
nm.transform(n.set(m.normals[3 * v], m.normals[3 * v + 1], m.normals[3 * v + 2]));
if (o.gameView()) {
p.z = -p.z;
n.z = -n.z;
}
int c = ri.vertexColors != null
&& (fl & RenderMesh.NO_VERTEX_COLOR) == 0
&& v < ri.vertexColors.length
? doubled(ri.vertexColors[v])
: 0xffffffff;
c = mul(c, tc);
tri[k] = new float[] {p.x, p.y, p.z, n.x, n.y, n.z, m.uvs[6 * t + 2 * k], m.uvs[6 * t + 2 * k + 1]};
rgba[k] = c;
}
if (o.gameView()) {
float[] x = tri[1];
tri[1] = tri[2];
tri[2] = x;
int y = rgba[1];
rgba[1] = rgba[2];
rgba[2] = y;
}
// unit normals (glTF requires them): face normal where the vertex normal is zero
e1.set(tri[1][0] - tri[0][0], tri[1][1] - tri[0][1], tri[1][2] - tri[0][2]);
e2.set(tri[2][0] - tri[0][0], tri[2][1] - tri[0][1], tri[2][2] - tri[0][2]);
e1.cross(e2);
if (!(e1.lengthSquared() > 1e-14f)) {
continue;
}
e1.normalize();
for (float[] q : tri) {
float len = (float) Math.sqrt(q[3] * q[3] + q[4] * q[4] + q[5] * q[5]);
if (len > 1e-6f) {
q[3] /= len;
q[4] /= len;
q[5] /= len;
} else {
q[3] = e1.x;
q[4] = e1.y;
q[5] = e1.z;
}
if (page < 0) {
q[6] = 0;
q[7] = 0;
}
}
boolean dbl = (fl & RenderMesh.DOUBLE_SIDED) != 0;
int blend = m.blend[t];
Prim pr = prims.computeIfAbsent(
group + "/" + page + "/" + blend + "/" + dbl, key -> new Prim(group, page, blend, dbl));
for (int k = 0; k < 3; k++) {
pr.tris.add(pr.vertex(tri[k], rgba[k]));
}
}
}
List<Prim> l = new ArrayList<>();
for (Prim pr : prims.values()) {
if (!pr.tris.isEmpty()) {
l.add(pr);
}
}
String name = s.settings().levelName();
return new Geometry(name == null || name.isBlank() ? "level" : name, l, List.copyOf(rs.pages));
}
/** Game vertex colour (0xRRGGBBAA, 0x80 = 1.0) doubled with clamp, as the viewport does. */
static int doubled(int rgba) {
int r = Math.min(255, ((rgba >>> 24) & 0xff) * 2);
int g = Math.min(255, ((rgba >>> 16) & 0xff) * 2);
int b = Math.min(255, ((rgba >>> 8) & 0xff) * 2);
return r << 24 | g << 16 | b << 8 | (rgba & 0xff);
}
static int mul(int a, int b) {
int r = ((a >>> 24) & 0xff) * ((b >>> 24) & 0xff) / 255;
int g = ((a >>> 16) & 0xff) * ((b >>> 16) & 0xff) / 255;
int bl = ((a >>> 8) & 0xff) * ((b >>> 8) & 0xff) / 255;
int al = (a & 0xff) * (b & 0xff) / 255;
return r << 24 | g << 16 | bl << 8 | al;
}
}
@@ -70,8 +70,15 @@ public final class SceneRenderer {
private final Map<Node, Integer> ids = new IdentityHashMap<>();
private final Map<Integer, Node> nodes = new HashMap<>();
private final int[] counters = new int[32];
/** Removals since the last pick-id cleanup; ids of absent nodes are dropped every PRUNE_AFTER. */
private int removedSincePrune;
private static final int PRUNE_AFTER = 256;
private final Map<TileModel, RenderMesh> modelMeshes = new IdentityHashMap<>();
/** Meshes of "no collision" tiles: as modelMeshes, without the collision overlay. */
private final Map<TileModel, RenderMesh> visualMeshes = new IdentityHashMap<>();
private final Map<Tile, RenderInstance> tileInst = new IdentityHashMap<>();
private final Map<Strat, RenderInstance> stratInst = new IdentityHashMap<>();
private final List<RenderInstance> brushInst = new ArrayList<>();
@@ -107,6 +114,7 @@ public final class SceneRenderer {
scene = s;
ids.clear();
nodes.clear();
removedSincePrune = 0;
Arrays.fill(counters, 0);
hidden.clear();
selected.clear();
@@ -124,6 +132,9 @@ public final class SceneRenderer {
return;
}
Node n = e.node();
if (e.type() == SceneEvent.Type.REMOVED) {
removedSincePrune++;
}
switch (n.kind()) {
case TEXTURE_PAGE, TEXTURE, TEXTURE_ANIM -> dirtyTextures = true;
case RAW_CHUNK -> dirtyAll = true;
@@ -176,6 +187,7 @@ public final class SceneRenderer {
}
}
for (TileModel m : models) {
visualMeshes.remove(m);
if (scene.contains(m)) {
modelMeshes.put(m, tileMesh(m));
} else {
@@ -205,6 +217,30 @@ public final class SceneRenderer {
dirtyMarkers = false;
rebuildMarkers();
}
if (removedSincePrune >= PRUNE_AFTER) {
pruneIds();
}
}
/**
* Forgets the pick ids of nodes no longer in the scene (deleted, or undone additions). A node
* that comes back by undo/redo simply gets a new id; nothing keeps ids of absent nodes.
*/
private void pruneIds() {
removedSincePrune = 0;
ids.entrySet().removeIf(e -> {
Node n = e.getKey();
if (scene.contains(n) || selected.contains(n)) {
return false;
}
nodes.remove(e.getValue());
return true;
});
}
/** Pick ids currently assigned (for tests). */
int assignedIds() {
return ids.size();
}
// ------------------------------------------------------------ ids
@@ -340,6 +376,7 @@ public final class SceneRenderer {
rs.markers.clear();
rs.overlays.clear();
modelMeshes.clear();
visualMeshes.clear();
tileInst.clear();
stratInst.clear();
brushInst.clear();
@@ -434,6 +471,19 @@ public final class SceneRenderer {
return rm;
}
private static RenderMesh withoutOverlay(RenderMesh m) {
RenderMesh c = new RenderMesh(m.name, m.vertexCount(), m.triangleCount());
System.arraycopy(m.positions, 0, c.positions, 0, m.positions.length);
System.arraycopy(m.normals, 0, c.normals, 0, m.normals.length);
System.arraycopy(m.triangles, 0, c.triangles, 0, m.triangles.length);
System.arraycopy(m.uvs, 0, c.uvs, 0, m.uvs.length);
System.arraycopy(m.pages, 0, c.pages, 0, m.pages.length);
System.arraycopy(m.triColors, 0, c.triColors, 0, m.triColors.length);
System.arraycopy(m.triFlags, 0, c.triFlags, 0, m.triFlags.length);
System.arraycopy(m.blend, 0, c.blend, 0, m.blend.length);
return c;
}
/** Texture pages as uploaded (index = page number). */
public List<TexturePage> pages() {
return rs.pages;
@@ -454,6 +504,11 @@ public final class SceneRenderer {
mesh = tileMesh(t.model());
modelMeshes.put(t.model(), mesh);
}
if (t.noCollision()) {
RenderMesh full = mesh;
// same triangles, no collision overlay (the build drops the tile's collision)
mesh = visualMeshes.computeIfAbsent(t.model(), m -> withoutOverlay(full));
}
RenderInstance ri = old;
if (ri == null || ri.mesh != mesh) {
if (old != null) {
@@ -123,6 +123,7 @@ action.geo.deathPlane = 06-geometry.md#death-floors-and-the-death-plane
action.geo.reimport = 07-meshes.md#re-import-mesh
action.geo.libSave = 07-meshes.md#saving-to-the-library
action.geo.exportObj = 07-meshes.md#export-selection-as-obj
action.file.exportGltf = 07-meshes.md#export-the-level-as-gltf
action.geo.* = 06-geometry.md#rotating-scaling-and-mirroring
action.tex.manager = 08-textures.md#texture-manager
action.tex.replace = 08-textures.md#replacing-pixels
@@ -0,0 +1,121 @@
package com.eliasmueller.llamamod.editor.io;
import com.eliasmueller.llamamod.editor.model.Brush;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.PathNode;
import com.eliasmueller.llamamod.editor.model.Prop;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.TileModel;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Batch;
import com.eliasmueller.llamamod.editor.model.cmd.Command;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.format.Level;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.Map;
/**
* Not a unit test: test level for "no collision" tiles. A plaza; two walls baked from box brushes
* into scene tiles (like library tiles); the north wall's tiles are visual only. The llama starts
* west of the north wall facing east. Args: CD folder, output .llamalevel, chapter.
*/
public final class NoCollisionDemo {
private NoCollisionDemo() {}
static Brush box(Scene s, double x0, double z0, double x1, double z1, double y0, double y1, String top) {
Brush b = s.createBrush(BrushKind.BOX);
Map<String, Object> p = Map.of("x0", x0, "z0", z0, "x1", x1, "z1", z1, "y0", y0, "y1", y1);
p.forEach(b::setRaw);
if (top != null) {
b.setRaw("top", top);
}
new AddNode(b).apply(s);
return b;
}
/** Compiles one brush in a scratch copy of the scene and adds its tiles/models as scene nodes. */
static int bake(Scene s, Brush b, boolean noCollision) {
Scene scratch = s;
var others = new ArrayList<>(s.brushes());
others.remove(b);
for (Brush o : others) {
o.setRaw("enabled", false);
}
BrushCompiler.Output out = BrushCompiler.compile(scratch, new ArrayList<>());
for (Brush o : others) {
o.setRaw("enabled", true);
}
if (!out.pages().isEmpty()) {
throw new IllegalStateException("brush needs new texture pages");
}
Map<TileModel, TileModel> models = new IdentityHashMap<>();
for (TileModel m : out.models()) {
TileModel c = (TileModel) s.create(NodeKind.TILE_MODEL);
copy(m, c);
new AddNode(c).apply(s);
models.put(m, c);
}
for (Tile t : out.tiles()) {
Tile c = (Tile) s.create(NodeKind.TILE);
copy(t, c);
c.setRaw("model", models.get(t.model()));
c.setRaw("noCollision", noCollision);
new AddNode(c).apply(s);
}
Edits.delete(s, java.util.List.of(b)).apply(s);
return out.tiles().size();
}
static void copy(com.eliasmueller.llamamod.editor.model.Node from, com.eliasmueller.llamamod.editor.model.Node to) {
for (Prop p : from.props()) {
if (p.settable() && !p.name().equals("name")) {
Object v = from.get(p.name());
if (v != null || p.nullable()) {
to.setRaw(p.name(), v);
}
}
}
}
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(a[0]));
Project pr = Project.create(cd, "new", Templates.DEFAULT_BASE);
Scene s = pr.scene();
s.settings().setRaw("name", "No Collision Test");
s.settings().setRaw("targetChapter", Integer.parseInt(a[2]));
box(s, 4, 4, 30, 24, -1, 0, null);
Brush north = box(s, 14, 5, 15, 12, 0, 2, null);
Brush south = box(s, 14, 15, 15, 23, 0, 2, null);
System.out.println("north wall tiles (visual only): " + bake(s, north, true));
System.out.println("south wall tiles (solid): " + bake(s, south, false));
double px = 10.5, pz = 8.5, yaw = 90;
Batch b = (Batch) Edits.placeStrat(s, "Llama", 0, px, 0, pz, yaw);
int k = 0;
for (Command c : b.commands()) {
if (c instanceof AddNode an && an.node() instanceof PathNode p) {
p.setRaw("x", Edits.fixed(k == 0 ? px - 8 : px));
p.setRaw("y", Edits.fixed(k == 0 ? 1.5 : 0));
p.setRaw("z", Edits.fixed(pz));
k++;
}
}
b.apply(s);
WadBuilder.Result r = WadBuilder.buildWithReport(s);
Level l = Level.parse(r.wad());
int noColl = 0;
for (var m : l.models()) {
noColl +=
m.coll.get(0).size() + m.coll.get(1).size() + m.coll.get(2).size() == 0 && !m.faces.isEmpty()
? 1
: 0;
}
System.out.println("built " + r.wad().length + " bytes, " + r.stats() + ", models with faces but no collision: "
+ noColl + ", warnings " + r.warnings());
ProjectIO.save(pr, Path.of(a[1]), cd);
System.out.println("saved " + a[1]);
}
}
@@ -0,0 +1,82 @@
package com.eliasmueller.llamamod.editor.io;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.format.Level;
import com.eliasmueller.llamamod.format.Model;
import java.util.List;
import org.junit.jupiter.api.Test;
/** "No collision (visual only)" tiles: the build omits their collision polygons (needs the CD). */
class NoCollisionTilesTest {
private static int coll(Model m) {
return m.coll.get(0).size() + m.coll.get(1).size() + m.coll.get(2).size();
}
@Test
void noCollisionTilesGetAModelCopyWithoutCollision() throws Exception {
Bases cd = Bases.fromCd(GoldenTest.cd());
Project p = Project.create(cd, "edit", "t1l1m001");
Scene s = p.scene();
byte[] original = WadBuilder.build(s);
Level before = Level.parse(original);
int nModels = before.models().size();
// two tiles sharing a model with collision, a third one of the same model stays solid
Tile a = null;
Tile b = null;
Tile solid = null;
for (Tile t : s.tiles()) {
if (t.model() == null || t.model().collisionCount() == 0) {
continue;
}
long same = s.tiles().stream().filter(o -> o.model() == t.model()).count();
if (same >= 3) {
List<Tile> l =
s.tiles().stream().filter(o -> o.model() == t.model()).toList();
a = l.get(0);
b = l.get(1);
solid = l.get(2);
break;
}
}
assertTrue(a != null, "a shared model with collision");
int ia = s.tiles().indexOf(a);
int ib = s.tiles().indexOf(b);
int is = s.tiles().indexOf(solid);
int orig = before.map().tiles.get(ia).model();
a.prop("noCollision").setRaw(a, true);
b.prop("noCollision").setRaw(b, true);
assertTrue(a.worldCollisionPolygons().isEmpty(), "editor collision follows the flag");
Level after = Level.parse(WadBuilder.build(s));
assertEquals(nModels + 1, after.models().size(), "one copy per model");
assertEquals(before.map().tiles.size(), after.map().tiles.size(), "the tile stays in the level");
int ma = after.map().tiles.get(ia).model();
assertEquals(ma, after.map().tiles.get(ib).model(), "tiles of one model share the copy");
assertNotEquals(orig, ma);
assertEquals(0, coll(after.models().get(ma)), "no collision polygons");
Model src = after.models().get(orig);
Model cp = after.models().get(ma);
assertTrue(coll(src) > 0);
assertEquals(src.verts.size(), cp.verts.size(), "same vertices");
assertEquals(src.faces.size(), cp.faces.size(), "same faces");
assertEquals(orig, after.map().tiles.get(is).model(), "other tiles keep the solid model");
assertEquals(before.map().tiles.get(ia).x(), after.map().tiles.get(ia).x());
assertArrayEquals(
before.map().tilesFixed.get(ia), after.map().tilesFixed.get(ia), "placement unchanged");
assertEquals(before.map().strats.size(), after.map().strats.size());
// saved with the project
Project q = ProjectIO.read(ProjectIO.write(p, cd), cd);
assertEquals(2, q.scene().tiles().stream().filter(Tile::noCollision).count());
// off again: byte-identical original
a.prop("noCollision").setRaw(a, false);
b.prop("noCollision").setRaw(b, false);
assertArrayEquals(original, WadBuilder.build(s));
}
}
@@ -0,0 +1,83 @@
package com.eliasmueller.llamamod.editor.model.cmd;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Paint/erase strokes (as issued by the tiles tool) are one undo step each. */
class StrokeTest {
private static Scene village() throws Exception {
String cd = System.getProperty("llamamod.cd", "");
assumeTrue(!cd.isEmpty() && Files.isDirectory(Path.of(cd, "wads")), "original CD not available");
return Project.create(Bases.fromCd(Path.of(cd)), "edit", "t1l1m001").scene();
}
@Test
void eraseStrokeIsOneUndoStep() throws Exception {
Scene s = village();
History h = new History(s);
List<Tile> before = new ArrayList<>(s.tiles());
Object stroke = new Object();
List<Tile> erased = before.subList(10, 16);
for (Tile t : erased) {
h.execute(new Stroke("Erase tiles", Edits.delete(s, List.of(t)), stroke));
}
assertEquals(before.size() - erased.size(), s.tiles().size());
assertEquals(List.of("Erase tiles"), h.undoLabels());
h.undo();
assertEquals(before, s.tiles(), "all tiles back, same order");
assertFalse(h.canUndo());
h.redo();
assertEquals(before.size() - erased.size(), s.tiles().size());
for (Tile t : erased) {
assertFalse(s.contains(t));
}
// the next stroke is its own step
h.execute(new Stroke("Erase tiles", Edits.delete(s, List.of(before.get(30))), new Object()));
assertEquals(2, h.undoLabels().size());
}
@Test
void paintStrokeMixingAddsAndSwapsIsOneStep() throws Exception {
Scene s = village();
History h = new History(s);
int n = s.tiles().size();
Tile old = s.tiles().get(3);
var oldModel = old.model();
var otherModel =
s.tileModels().stream().filter(m -> m != oldModel).findFirst().orElseThrow();
Object stroke = new Object();
for (int i = 0; i < 4; i++) {
Tile t = (Tile) s.create(NodeKind.TILE);
t.setRaw("x", 1.5 + i);
t.setRaw("y", 0.0);
t.setRaw("z", -1.5);
t.setRaw("model", otherModel);
h.execute(new Stroke("Paint tiles", new AddNode(t), stroke));
if (i == 1) {
h.execute(new Stroke(
"Paint tiles",
new Batch("Paint tiles", List.of(SetProp.of(old, "model", otherModel))),
stroke));
}
}
assertEquals(n + 4, s.tiles().size());
assertTrue(old.model() == otherModel);
assertEquals(1, h.undoLabels().size());
h.undo();
assertEquals(n, s.tiles().size());
assertTrue(old.model() == oldModel);
}
}
@@ -0,0 +1,295 @@
package com.eliasmueller.llamamod.editor.tools.gltf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.MeshImport;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.BrushKind;
import com.eliasmueller.llamamod.editor.model.MeshData;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.platform.Json;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
/** glTF export: structural validation of .glb/.gltf and a round trip through the mesh importer. */
class GltfExportTest {
static Bases cd() {
String cd = System.getProperty("llamamod.cd", "");
assumeTrue(!cd.isEmpty() && Files.isDirectory(Path.of(cd, "wads")), "original CD not available (llamamod.cd)");
return Bases.fromCd(Path.of(cd));
}
/** Parsed file: JSON + binary buffer. */
record Parsed(Map<?, ?> json, byte[] bin) {}
static Parsed parseGlb(byte[] d) {
ByteBuffer b = ByteBuffer.wrap(d).order(ByteOrder.LITTLE_ENDIAN);
assertEquals(0x46546C67, b.getInt(0), "magic glTF");
assertEquals(2, b.getInt(4), "version 2");
assertEquals(d.length, b.getInt(8), "total length");
int jl = b.getInt(12);
assertEquals(0x4E4F534A, b.getInt(16), "JSON chunk");
assertEquals(0, jl % 4, "JSON chunk padded");
int bo = 20 + jl;
int bl = b.getInt(bo);
assertEquals(0x004E4942, b.getInt(bo + 4), "BIN chunk");
assertEquals(0, bl % 4);
assertEquals(d.length, bo + 8 + bl, "no trailing bytes");
Map<?, ?> j = (Map<?, ?>) Json.parse(new String(d, 20, jl, StandardCharsets.UTF_8));
return new Parsed(j, Arrays.copyOfRange(d, bo + 8, bo + 8 + bl));
}
static int num(Object o) {
return ((Number) o).intValue();
}
static List<?> list(Map<?, ?> m, String k) {
return m.get(k) instanceof List<?> l ? l : List.of();
}
static int comps(String type) {
return switch (type) {
case "SCALAR" -> 1;
case "VEC2" -> 2;
case "VEC3" -> 3;
case "VEC4" -> 4;
default -> throw new AssertionError(type);
};
}
static int size(int ct) {
return switch (ct) {
case 5121 -> 1;
case 5123 -> 2;
case 5125, 5126 -> 4;
default -> throw new AssertionError("component type " + ct);
};
}
/** Checks what the glTF validator would: views, accessors, bounds, indices, normals, images. Returns triangles. */
static int validate(Parsed p) throws Exception {
Map<?, ?> j = p.json();
assertEquals("2.0", ((Map<?, ?>) j.get("asset")).get("version"));
Map<?, ?> buffer = (Map<?, ?>) list(j, "buffers").getFirst();
assertTrue(num(buffer.get("byteLength")) <= p.bin().length);
ByteBuffer bin = ByteBuffer.wrap(p.bin()).order(ByteOrder.LITTLE_ENDIAN);
List<?> views = list(j, "bufferViews");
for (Object o : views) {
Map<?, ?> v = (Map<?, ?>) o;
int off = num(v.get("byteOffset"));
assertTrue(off >= 0 && off + num(v.get("byteLength")) <= num(buffer.get("byteLength")), "view in buffer");
}
List<?> accessors = list(j, "accessors");
int triangles = 0;
for (Object mo : list(j, "meshes")) {
for (Object po : list((Map<?, ?>) mo, "primitives")) {
Map<?, ?> prim = (Map<?, ?>) po;
assertEquals(4, num(prim.get("mode")));
Map<?, ?> attr = (Map<?, ?>) prim.get("attributes");
int count = -1;
for (Map.Entry<?, ?> e : attr.entrySet()) {
Map<?, ?> a = (Map<?, ?>) accessors.get(num(e.getValue()));
int n = num(a.get("count"));
if (count < 0) {
count = n;
}
assertEquals(count, n, "all attributes have the same count");
Map<?, ?> v = (Map<?, ?>) views.get(num(a.get("bufferView")));
int c = comps((String) a.get("type"));
int ct = num(a.get("componentType"));
assertEquals(0, num(v.get("byteOffset")) % 4, "aligned");
assertTrue(n * c * size(ct) <= num(v.get("byteLength")), "accessor fits its view");
int base = num(v.get("byteOffset"));
switch ((String) e.getKey()) {
case "POSITION" -> {
List<?> min = (List<?>) a.get("min");
List<?> max = (List<?>) a.get("max");
float[] lo = {Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE};
float[] hi = {-Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE};
for (int i = 0; i < n; i++) {
for (int k = 0; k < 3; k++) {
float f = bin.getFloat(base + (i * 3 + k) * 4);
assertTrue(Float.isFinite(f));
lo[k] = Math.min(lo[k], f);
hi[k] = Math.max(hi[k], f);
}
}
for (int k = 0; k < 3; k++) {
assertEquals(lo[k], ((Number) min.get(k)).floatValue(), "exact min");
assertEquals(hi[k], ((Number) max.get(k)).floatValue(), "exact max");
}
}
case "NORMAL" -> {
for (int i = 0; i < n; i++) {
double l = 0;
for (int k = 0; k < 3; k++) {
float f = bin.getFloat(base + (i * 3 + k) * 4);
l += f * f;
}
assertEquals(1, Math.sqrt(l), 1e-3, "unit normal");
}
}
case "TEXCOORD_0" -> {
for (int i = 0; i < n * 2; i++) {
float f = bin.getFloat(base + i * 4);
assertTrue(f >= -1e-4 && f <= 1.0001f, "uv on the page: " + f);
}
}
case "COLOR_0" -> {
assertEquals(5121, ct);
assertEquals(Boolean.TRUE, a.get("normalized"));
}
default -> throw new AssertionError("unexpected attribute " + e.getKey());
}
}
Map<?, ?> ia = (Map<?, ?>) accessors.get(num(prim.get("indices")));
int in = num(ia.get("count"));
assertEquals(0, in % 3);
Map<?, ?> iv = (Map<?, ?>) views.get(num(ia.get("bufferView")));
assertEquals(34963, num(iv.get("target")));
int ct = num(ia.get("componentType"));
int base = num(iv.get("byteOffset"));
for (int i = 0; i < in; i++) {
long ix = ct == 5123 ? bin.getShort(base + i * 2) & 0xffff : bin.getInt(base + i * 4) & 0xffffffffL;
assertTrue(ix < count, "index in range");
}
triangles += in / 3;
int mi = num(prim.get("material"));
assertTrue(mi < list(j, "materials").size());
}
}
for (Object mo : list(j, "materials")) {
Map<?, ?> pbr = (Map<?, ?>) ((Map<?, ?>) mo).get("pbrMetallicRoughness");
if (pbr.get("baseColorTexture") instanceof Map<?, ?> t) {
assertTrue(num(t.get("index")) < list(j, "textures").size());
}
}
for (Object io : list(j, "images")) {
Map<?, ?> img = (Map<?, ?>) io;
assertEquals("image/png", img.get("mimeType"));
Map<?, ?> v = (Map<?, ?>) views.get(num(img.get("bufferView")));
BufferedImage bi =
ImageIO.read(new ByteArrayInputStream(p.bin(), num(v.get("byteOffset")), num(v.get("byteLength"))));
assertNotNull(bi, "PNG decodes");
assertTrue(bi.getWidth() > 0 && bi.getHeight() > 0);
}
for (Object no : list(j, "nodes")) {
assertTrue(num(((Map<?, ?>) no).get("mesh")) < list(j, "meshes").size());
}
return triangles;
}
@Test
void villageExportsAsValidGlbAndGltf() throws Exception {
Project pr = Project.create(cd(), "edit", "t1l1m001");
Scene s = pr.scene();
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false);
assertTrue(g.triangles() > 20000, "village triangles: " + g.triangles());
byte[] glb = g.glb();
Parsed p = parseGlb(glb);
assertEquals(g.triangles(), validate(p));
assertTrue(list(p.json(), "images").size() > 5, "texture pages");
assertTrue(list(p.json(), "materials").size() > 3);
// vertex positions cover the tiles' cells (export space: z ≤ 0)
double[] lo = {1e9, 1e9, 1e9};
double[] hi = {-1e9, -1e9, -1e9};
for (Object a : list(p.json(), "accessors")) {
Map<?, ?> m = (Map<?, ?>) a;
if (m.get("min") instanceof List<?> mn) {
List<?> mx = (List<?>) m.get("max");
for (int k = 0; k < 3; k++) {
lo[k] = Math.min(lo[k], ((Number) mn.get(k)).doubleValue());
hi[k] = Math.max(hi[k], ((Number) mx.get(k)).doubleValue());
}
}
}
List<Tile> drawn = s.tiles().stream()
.filter(t -> t.model() != null && t.model().faceCount() > 0)
.toList();
double tx0 = drawn.stream().mapToDouble(Tile::x).min().orElseThrow();
double tx1 = drawn.stream().mapToDouble(Tile::x).max().orElseThrow();
double tz0 = drawn.stream().mapToDouble(Tile::z).min().orElseThrow();
assertTrue(lo[0] < tx0 + 1 && hi[0] > tx1 - 1, "x range " + lo[0] + ".." + hi[0]);
assertTrue(lo[2] < tz0 + 1 && hi[2] <= 3, "export-space z " + lo[2] + ".." + hi[2]);
// .gltf: same content, buffer as data URI
Map<?, ?> j = (Map<?, ?>) Json.parse(new String(g.gltf(), StandardCharsets.UTF_8));
String uri = (String) ((Map<?, ?>) list(j, "buffers").getFirst()).get("uri");
assertTrue(uri.startsWith("data:application/octet-stream;base64,"));
byte[] bin = Base64.getDecoder().decode(uri.substring(uri.indexOf(',') + 1));
assertEquals(g.triangles(), validate(new Parsed(j, bin)));
// round trip through the editor's own importer (file space = export space, game z = −z)
MeshData md = MeshImport.parse("v.glb", glb, f -> null, "y", 1);
assertEquals(g.triangles(), md.triangleCount());
double[] b = md.bounds();
assertEquals(lo[0], b[0], 1e-3);
assertEquals(hi[0], b[3], 1e-3);
assertEquals(-hi[2], b[2], 1e-3);
assertEquals(-lo[2], b[5], 1e-3);
assertTrue(md.materialList().stream().filter(m -> m.image() != null).count() > 5, "textures read back");
assertNotNull(md.colors(), "vertex colours read back");
// game orientation: z mirrored, same triangles
GltfExport.Geometry m = GltfExport.collect(s, new GltfExport.Options(false, true), n -> false);
assertEquals(g.triangles(), m.triangles());
double[] mb = MeshImport.parse("m.glb", m.glb(), f -> null, "y", 1).bounds();
assertEquals(-b[5], mb[2], 1e-3);
assertEquals(-b[2], mb[5], 1e-3);
assertEquals(b[0], mb[0], 1e-3);
GltfExport.Geometry o = GltfExport.collect(s, new GltfExport.Options(true, false), n -> false);
assertTrue(o.triangles() > g.triangles(), "objects add triangles");
assertEquals(o.triangles(), validate(parseGlb(o.glb())));
}
@Test
void brushesAndExcludedTiles() throws Exception {
Scene s = Project.create(cd(), "new", "t1l1m001").scene();
Edits.addBrush(s, BrushKind.BOX, Map.of("x0", 10.0, "z0", 10.0, "x1", 12.0, "z1", 13.0, "y0", 0.0, "y1", 1.0))
.apply(s);
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false);
Parsed p = parseGlb(g.glb());
assertEquals(g.triangles(), validate(p));
assertTrue(g.triangles() >= 10, "box faces: " + g.triangles());
MeshData md = MeshImport.parse("b.glb", g.glb(), f -> null, "y", 1);
double[] b = md.bounds();
assertEquals(10, b[0], 1e-3);
assertEquals(12, b[3], 1e-3);
assertEquals(1, b[4], 1e-3);
// hidden brush left out
GltfExport.Geometry none = GltfExport.collect(s, new GltfExport.Options(false, false), n -> true);
assertEquals(0, none.triangles());
}
@Test
void excludedTilesAreLeftOut() throws Exception {
Scene s = Project.create(cd(), "edit", "t1l1m001").scene();
int all = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false)
.triangles();
Tile t = s.tiles().stream()
.filter(x -> x.model() != null && x.model().faceCount() > 4)
.findFirst()
.orElseThrow();
t.prop("excluded").setRaw(t, true);
int less = GltfExport.collect(s, new GltfExport.Options(false, false), n -> false)
.triangles();
assertTrue(less < all && less >= all - t.model().faceCount(), all + " → " + less);
}
}
@@ -0,0 +1,27 @@
package com.eliasmueller.llamamod.editor.tools.gltf;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Scene;
import java.nio.file.Files;
import java.nio.file.Path;
/** Writes sample exports for external validation: GltfSample CD OUTDIR level... (not a unit test). */
public final class GltfSample {
private GltfSample() {}
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(a[0]));
Path out = Files.createDirectories(Path.of(a[1]));
for (int i = 2; i < a.length; i++) {
Scene s = Project.create(cd, "edit", a[i]).scene();
GltfExport.Geometry g = GltfExport.collect(s, new GltfExport.Options(true, false), n -> false);
Files.write(out.resolve(a[i] + ".glb"), g.glb());
Files.write(out.resolve(a[i] + ".gltf"), g.gltf());
GltfExport.Geometry m = GltfExport.collect(s, new GltfExport.Options(false, true), n -> false);
Files.write(out.resolve(a[i] + "_game.glb"), m.glb());
System.out.println(a[i] + ": " + g.triangles() + " triangles, " + g.vertices() + " vertices, "
+ g.primitives() + " primitives");
}
}
}
@@ -0,0 +1,85 @@
package com.eliasmueller.llamamod.editor.ui.view;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Strat;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.editor.render.Viewport;
import java.awt.Dimension;
import java.nio.file.Path;
import java.util.List;
import javax.swing.JFrame;
import javax.swing.SwingUtilities;
/**
* Not a unit test: on an X display, switches levels and edits while rendering and prints the live
* GL buffer count (should return to the same value per level). Args: CD folder, levels...
*/
public final class GlLeakCheck {
private GlLeakCheck() {}
static void edt(Runnable r) throws Exception {
SwingUtilities.invokeAndWait(r);
}
public static void main(String[] a) throws Exception {
Bases cd = Bases.fromCd(Path.of(a[0]));
SceneRenderer sr = new SceneRenderer();
Viewport vp = new Viewport();
edt(() -> {
JFrame f = new JFrame("leak check");
vp.setPreferredSize(new Dimension(800, 500));
f.add(vp);
f.pack();
f.setVisible(true);
vp.setScene(sr.renderScene());
});
Thread.sleep(2000);
for (int round = 0; round < 3; round++) {
for (int i = 1; i < a.length; i++) {
Scene s = Project.create(cd, "edit", a[i]).scene();
edt(() -> {
sr.setScene(s);
vp.frameAll();
vp.renderNow();
});
Thread.sleep(300);
edt(vp::renderNow);
int afterLoad = Viewport.liveGlBuffers();
// edits: move tiles and objects, delete/undo, while rendering
History h = new History(s);
s.addListener(sr::sceneChanged);
List<Tile> tiles = s.tiles().subList(0, 40);
List<Strat> strats = s.strats();
for (int k = 0; k < 60; k++) {
int kk = k;
edt(() -> {
h.execute(Edits.translate(tiles, kk % 2 == 0 ? 1 : -1, 0, 0, null));
if (!strats.isEmpty()) {
h.execute(Edits.translate(List.of(strats.get(kk % strats.size())), 0, 0.1, 0, null));
}
h.execute(Edits.delete(s, List.of(tiles.get(kk % tiles.size()))));
h.undo();
sr.flush();
vp.renderNow();
});
}
edt(vp::renderNow);
System.out.printf(
"round %d %s: after load %d, after 60 edit rounds %d%n",
round, a[i], afterLoad, Viewport.liveGlBuffers());
}
}
edt(() -> {
sr.setScene(null);
vp.renderNow();
});
Thread.sleep(300);
edt(vp::renderNow);
System.out.println("no level: " + Viewport.liveGlBuffers());
System.exit(0);
}
}
@@ -0,0 +1,92 @@
package com.eliasmueller.llamamod.editor.ui.view;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.eliasmueller.llamamod.editor.io.Bases;
import com.eliasmueller.llamamod.editor.io.Project;
import com.eliasmueller.llamamod.editor.model.NodeKind;
import com.eliasmueller.llamamod.editor.model.Scene;
import com.eliasmueller.llamamod.editor.model.Tile;
import com.eliasmueller.llamamod.editor.model.cmd.AddNode;
import com.eliasmueller.llamamod.editor.model.cmd.Edits;
import com.eliasmueller.llamamod.editor.model.cmd.History;
import com.eliasmueller.llamamod.editor.render.RenderInstance;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Test;
/** Pick ids of deleted nodes are dropped (bounded); "no collision" tiles have no collision overlay. */
class SceneRendererIdsTest {
private static Scene village() throws Exception {
String cd = System.getProperty("llamamod.cd", "");
assumeTrue(!cd.isEmpty() && Files.isDirectory(Path.of(cd, "wads")), "original CD not available");
return Project.create(Bases.fromCd(Path.of(cd)), "edit", "t1l1m001").scene();
}
@Test
void idsOfDeletedNodesAreDropped() throws Exception {
Scene s = village();
SceneRenderer r = new SceneRenderer();
r.setScene(s);
s.addListener(r::sceneChanged);
History h = new History(s);
int start = r.assignedIds();
Tile keep = s.tiles().get(7);
int keepId = r.idOf(keep);
for (int i = 0; i < 1000; i++) {
Tile t = (Tile) s.create(NodeKind.TILE);
t.setRaw("x", 3.5);
t.setRaw("y", 0.0);
t.setRaw("z", -3.5);
t.setRaw("model", keep.model());
h.execute(new AddNode(t));
r.flush();
h.execute(Edits.delete(s, List.of(t)));
r.flush();
}
assertTrue(r.assignedIds() < start + 300, "bounded: " + r.assignedIds() + " vs " + start);
assertEquals(keepId, r.idOf(keep), "ids of live nodes are stable");
assertEquals(keep, r.nodeOf(keepId));
// a deleted tile that comes back by undo gets a working id again
Tile victim = s.tiles().get(9);
int old = r.idOf(victim);
for (int i = 0; i < 300; i++) {
h.execute(Edits.delete(s, List.of(victim)));
r.flush();
h.undo();
r.flush();
}
int now = r.idOf(victim);
assertNotEquals(0, now);
assertEquals(victim, r.nodeOf(now));
RenderInstance ri = r.renderScene().instance(now);
assertTrue(ri != null && ri.visible);
if (now != old) {
assertNull(r.nodeOf(old), "old id forgotten");
}
}
@Test
void noCollisionTilesHaveNoCollisionOverlay() throws Exception {
Scene s = village();
SceneRenderer r = new SceneRenderer();
r.setScene(s);
s.addListener(r::sceneChanged);
Tile t = s.tiles().stream()
.filter(x -> x.model() != null && x.model().collisionCount() > 0)
.findFirst()
.orElseThrow();
RenderInstance ri = r.renderScene().instance(r.idOf(t));
assertTrue(ri.mesh.overlay != null);
new History(s).execute(com.eliasmueller.llamamod.editor.model.cmd.SetProp.of(t, "noCollision", true));
r.flush();
RenderInstance after = r.renderScene().instance(r.idOf(t));
assertNull(after.mesh.overlay);
assertEquals(ri.mesh.triangleCount(), after.mesh.triangleCount());
}
}