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:
1 parent
59531a60b8
commit
2f22a56cec
30 files changed
+1737
-27
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> **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
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user