initial commit
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 24 Oct 2026 10:43:20 +0000 (13:43 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 24 Oct 2026 10:43:20 +0000 (13:43 +0300)
12 files changed:
.gitignore [new file with mode: 0644]
AGENTS.org [new file with mode: 0644]
oracle/HouseOracle.java [new file with mode: 0644]
oracle/house-compare.sh [new file with mode: 0755]
pom.xml [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2demos/AwtShellMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2demos/CoordinateSystemDemo.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2demos/HouseDemo.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2demos/LabelPass.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2demos/MinimalExampleMain.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2demos/RGBCubeDemo.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/aukio/e3d2demos/ShapesGalleryDemo.java [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..2e75012
--- /dev/null
@@ -0,0 +1,7 @@
+target/
+*.class
+/.idea/
+/.classpath
+/.project
+/.settings/
+/*.iml
diff --git a/AGENTS.org b/AGENTS.org
new file mode 100644 (file)
index 0000000..48d3b6a
--- /dev/null
@@ -0,0 +1,34 @@
+* aukio-3d-demos-2
+
+Demos rebuilt one by one from aukio-3d-demos (legacy engine) to run on
+Aukio 3D 2 (the rewrite). This repo is the migration checklist: each
+ported demo lands here as a Main class, and each gets an oracle
+comparison against the legacy engine's render of the same scene.
+
+* Hard rules for agents
+
+1. Both engines are dependencies ON PURPOSE (aukio-3d 1.x legacy +
+   aukio-3d-2 2.x). Packages never collide: e3d (legacy) vs e3d2 (new).
+2. A port is DONE only when its oracle comparison runs and the drift
+   is either below the harness tolerance or explicitly NAMED in this
+   file (like the poc oracle policy: new drift = defect unless listed
+   with a justification).
+3. Demos port by NEED (user picks), smallest/highest-value first.
+   Porting is mechanical: SolidPolygon quads -> addTriangle,
+   LightSource -> lights.add, pose -> camera.set (legacy -Y up maps to
+   e3d2 +Y up by negating y at the boundary).
+4. No old-API adapters. The legacy god-classes are the complexity
+   ceiling we escaped; we do not wrap them.
+
+* Migration checklist
+
+| legacy demo | status | oracle |
+| MinimalExample | PORTED 2026-10-11 | compare.sh: 158px (0.051%), meanAbs 0.12 — named edge/fill-rule class |
+| HouseDemo | PORTED 2026-10-14 (colored lights, lit-side bounce, falloff landed in engine; furniture winding fixed with the Shapes inside-out repair) | house-compare.sh: 210763px (68.6%), meanAbs 28.99 — named tonal classes: ambient base (40 vs 24), bounce gains, texel-vs-vertex lightmap grain; geometry/pools/shadows structurally matching; poc bake 2.0s vs legacy 11.5s |
+
+* Layout
+
+- =eu.svjatoslav.aukio.e3d2demos.<Name>Main= — the ported demo (new
+  engine only).
+- =oracle/<Name>Oracle.java + scripts= — legacy render + diff, the
+  same harness shape as aukio-3d-2/oracle.
diff --git a/oracle/HouseOracle.java b/oracle/HouseOracle.java
new file mode 100644 (file)
index 0000000..5f89c31
--- /dev/null
@@ -0,0 +1,163 @@
+package eu.svjatoslav.aukio.e3d2demos.oracle;
+
+import eu.svjatoslav.aukio.e3d.examples.house_demo.HouseDemo;
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.headless.Snapshot;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.RenderingContext;
+import eu.svjatoslav.aukio.e3d.renderer.raster.ShapeCollection;
+import eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightSource;
+import eu.svjatoslav.aukio.e3d.renderer.raster.lighting.LightingManager;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.gi.GiPass;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * House oracle: the HouseDemo scene baked through BOTH engines' GI and
+ * rendered from the same viewpoint (the demo's default: kitchen, looking
+ * west through both doorways).
+ *
+ * Drift is NAMED, not zero (same policy as aukio-3d-2/oracle): the direct
+ * models differ by design (legacy inverse-square falloff + additive lights
+ * vs e3d2 no-falloff + per-channel max-pool; legacy ambient 24 raised to
+ * 40 here to match the e3d2 AMBIENT constant), and the bounce samplers
+ * differ. Light markers are EXCLUDED on both sides: geometry enclosing a
+ * light point swallows its shadow rays (blacked-out lamp), and the two
+ * engines' marker shapes differ — the oracle compares GI, not markers.
+ *
+ * Pose/scale conventions follow oracle/GiOracle.java in aukio-3d-2
+ * (640x480, poc scale 300, per-engine angle formulas — proven aligned).
+ */
+public final class HouseOracle {
+
+    private static final int W = 640, H = 480;
+    private static final int BLACK = 0xFF000000;
+
+    // Eye (shared logical position; legacy negates y). Target: living-room
+    // center — kitchen view west through both doorways.
+    private static final double EX = 480.94, EY = 130.17, EZ = 126.27;
+    private static final double TX = -800, TY = 130, TZ = 0;
+
+    public static void main(final String[] args) throws Exception {
+        final File outDir = new File(args.length > 0 ? args[0] : "target/house-oracle");
+        outDir.mkdirs();
+
+        // ---------- e3d2 side ----------
+        final Substrate data = new Substrate();
+        eu.svjatoslav.aukio.e3d2demos.HouseDemo.buildScene(data);
+        eu.svjatoslav.aukio.e3d2demos.HouseDemo.addLights(data, false);
+
+        final long t0 = System.currentTimeMillis();
+        final GiPass gi = new GiPass(4, 1e9);
+        gi.apply(data);
+        final long dl = System.currentTimeMillis() + 300_000;
+        while (!gi.isQuiescent() && System.currentTimeMillis() < dl) {
+            Thread.sleep(50);
+        }
+        System.out.println("poc converged=" + gi.isConverged()
+                + " bakeMs=" + (System.currentTimeMillis() - t0));
+        gi.stop();
+
+        data.image = new ImageStore(W, H);
+        data.image.clear(BLACK);
+        final double fx = TX - EX, fy = TY - EY, fz = TZ - EZ;
+        final double fl = Math.sqrt(fx * fx + fy * fy + fz * fz);
+        data.camera.set(EX, EY, EZ, Math.atan2(fx, fz), Math.asin(fy / fl), 300);
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.runOnce(data);
+        writePng(new File(outDir, "house-poc.png"), data.image.pixels);
+
+        // ---------- Aukio 3D (legacy) side, y negated ----------
+        final ShapeCollection scene = new ShapeCollection();
+        final LightmappedCompositeShape house = new LightmappedCompositeShape();
+        house.setLightmappingEnabled(true);
+        house.setLightmapUnitsPerTexel(3.0);
+        HouseDemo.buildHouse(house);
+        HouseDemo.addFurniture(house);
+        scene.addShape(house);
+
+        final LightingManager lighting = new LightingManager();
+        lighting.setAmbientLight(new Color(40, 40, 40, 255));  // match e3d2 AMBIENT
+        lighting.setSkyColor(new Color(18, 24, 34, 255));      // match e3d2 SKY_*
+        lighting.addLight(new LightSource(new Point3D(-800, -240, 0),
+                Color.hex("FFD890"), 6.0));
+        lighting.addLight(new LightSource(new Point3D(0, -240, 0),
+                Color.hex("D8E4FF"), 5.0));
+        lighting.addLight(new LightSource(new Point3D(800, -240, 0),
+                Color.hex("FFB060"), 6.0));
+        lighting.addLight(new LightSource(new Point3D(250, -60, -250),
+                Color.hex("60FF90"), 2.0));
+
+        final double ay = -EY;
+        final double afx = TX - EX, afy = -TY - ay, afz = TZ - EZ;
+        final double afl = Math.sqrt(afx * afx + afy * afy + afz * afz);
+        final String pose = EX + ", " + ay + ", " + EZ + ", "
+                + Math.atan2(-afx, afz) + ", " + (-Math.asin(afy / afl)) + ", 0.0";
+
+        // warm-up render FROM THE FINAL VIEWPOINT: the legacy GI snapshot
+        // collects only what the warm-up frame queued (see GiOracle).
+        Snapshot.renderInto(scene, Snapshot.cameraFromPose(pose),
+                new RenderingContext(W, H, 1), BLACK);
+
+        final long t1 = System.currentTimeMillis();
+        final GlobalIllumination aukioGi = new GlobalIllumination(scene, lighting, 4);
+        aukioGi.start();
+        final long dl2 = System.currentTimeMillis() + 600_000;
+        while (!aukioGi.isConverged() && System.currentTimeMillis() < dl2) {
+            Thread.sleep(250);
+        }
+        System.out.println("aukio converged=" + aukioGi.isConverged()
+                + " bakeMs=" + (System.currentTimeMillis() - t1));
+        aukioGi.stop();
+
+        final RenderingContext ctx = new RenderingContext(W, H, 1);
+        Snapshot.renderInto(scene, Snapshot.cameraFromPose(pose), ctx, BLACK);
+        for (int i = 0; i < ctx.pixels.length; i++) {
+            ctx.pixels[i] |= 0xFF000000;
+        }
+        writePng(new File(outDir, "house-aukio.png"), ctx.pixels);
+
+        // ---------- diff ----------
+        final int[] a = data.image.pixels;
+        final int[] b = ctx.pixels;
+        final int[] heat = new int[W * H];
+        int over = 0;
+        long sumAbs = 0;
+        int maxDiff = 0;
+        for (int i = 0; i < W * H; i++) {
+            int d = 0;
+            for (int ch = 0; ch < 3; ch++) {
+                d = Math.max(d, Math.abs(((a[i] >> (ch * 8)) & 0xFF) - ((b[i] >> (ch * 8)) & 0xFF)));
+            }
+            sumAbs += d;
+            maxDiff = Math.max(maxDiff, d);
+            if (d > 16) {
+                over++;
+                heat[i] = 0xFF000000 | (Math.min(255, d * 4) << 16);
+            }
+        }
+        writePng(new File(outDir, "house-diff.png"), heat);
+        System.out.printf("pixels>16: %d (%.3f%%), meanAbs=%.2f, max=%d%n",
+                over, 100.0 * over / (W * H), (double) sumAbs / (W * H), maxDiff);
+        System.out.println("out: " + outDir.getAbsolutePath());
+    }
+
+    private static void writePng(final File f, final int[] argb) throws Exception {
+        final BufferedImage img = new BufferedImage(W, H, BufferedImage.TYPE_INT_ARGB);
+        img.setRGB(0, 0, W, H, argb, 0, W);
+        ImageIO.write(img, "png", f);
+    }
+}
diff --git a/oracle/house-compare.sh b/oracle/house-compare.sh
new file mode 100755 (executable)
index 0000000..862f644
--- /dev/null
@@ -0,0 +1,28 @@
+#!/bin/sh
+# House GI oracle: HouseDemo scene through e3d2 GI and legacy Aukio 3D GI,
+# side by side + diff. Same harness shape as aukio-3d-2/oracle/gi-compare.sh.
+set -e
+cd "${0%/*}/.."
+
+mvn -q compile
+
+LEGACY=../aukio-3d
+LEGACY_DEMOS=../aukio-3d-demos
+for R in "$LEGACY" "$LEGACY_DEMOS"; do
+  if [ ! -d "$R/target/classes" ]; then
+    (cd "$R" && mvn -q compile)
+  fi
+  if [ ! -f "$R/target/cp.txt" ]; then
+    (cd "$R" && mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt)
+  fi
+done
+
+# demos-2 pom depends on BOTH engines; its classpath covers e3d2 + e3d.
+if [ ! -f target/cp.txt ]; then
+  mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt
+fi
+
+CP="target/classes:$(cat target/cp.txt):$LEGACY/target/classes:$LEGACY_DEMOS/target/classes:$(cat "$LEGACY/target/cp.txt"):$(cat "$LEGACY_DEMOS/target/cp.txt")"
+mkdir -p target/oracle-classes
+javac -cp "$CP" -d target/oracle-classes oracle/HouseOracle.java
+java -cp "target/oracle-classes:$CP" eu.svjatoslav.aukio.e3d2demos.oracle.HouseOracle target/house-oracle
diff --git a/pom.xml b/pom.xml
new file mode 100644 (file)
index 0000000..1b11ed6
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>eu.svjatoslav</groupId>
+  <artifactId>aukio-3d-demos-2</artifactId>
+  <version>2.0.0-SNAPSHOT</version>
+  <packaging>jar</packaging>
+
+  <properties>
+    <maven.compiler.release>21</maven.compiler.release>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+  </properties>
+
+  <dependencies>
+    <!-- the new engine -->
+    <dependency>
+      <groupId>eu.svjatoslav</groupId>
+      <artifactId>aukio-3d-2</artifactId>
+      <version>2.0.0-SNAPSHOT</version>
+    </dependency>
+  </dependencies>
+
+</project>
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2demos/AwtShellMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2demos/AwtShellMain.java
new file mode 100644 (file)
index 0000000..44a9393
--- /dev/null
@@ -0,0 +1,71 @@
+package eu.svjatoslav.aukio.e3d2demos;
+
+import eu.svjatoslav.aukio.e3d2.ProceduralTerrainSource;
+import eu.svjatoslav.aukio.e3d2.app.AwtShell;
+import eu.svjatoslav.aukio.e3d2.kernel.GuiStore;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CameraPass;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.SpaceMousePass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.WorldStreamer;
+import eu.svjatoslav.aukio.e3d2.systems.gui.DiagnosticsHudPass;
+import eu.svjatoslav.aukio.e3d2.systems.gui.GuiPass;
+import eu.svjatoslav.aukio.e3d2.systems.gui.GuiRenderPass;
+import eu.svjatoslav.aukio.e3d2.systems.gui.SdfFont;
+
+/**
+ * The interactive app: streaming terrain + GUI + HUD in an AWT window.
+ * WASD + QE fly, mouse looks, Quit button works. Everything the shell
+ * needs beyond the kernel is wired here — this is the reference for
+ * how an application assembles the engine.
+ */
+public final class AwtShellMain {
+
+    public static void main(final String[] args) {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(960, 600);
+        data.camera.set(16, 9, 16, 0.35, -0.28, 450);
+        data.guiFont = SdfFont.build(48);
+
+        data.gui.panels.add(new GuiStore.Panel(0, 0, 960, 44, 0xC0182028));
+        data.gui.labels.add(new GuiStore.Label(12, 33, 0.45, 0xFFEEE8D5,
+                "AUKIO 3D — WASD + QE fly, mouse looks"));
+        data.gui.buttons.add(new GuiStore.Button(840, 7, 108, 30, "quit", "Quit"));
+
+        final Kernel kernel = new Kernel();
+        kernel.add(new WorldStreamer(new ProceduralTerrainSource(), 32, 3));
+        kernel.add(new GuiPass());
+        kernel.add(new SpaceMousePass());
+        kernel.add(new CameraPass());
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.add(new DiagnosticsHudPass());
+        kernel.add(new GuiRenderPass());
+
+        final AwtShell shell = new AwtShell(data, kernel, "Aukio 3D 2");
+        shell.open();
+
+        // app-level click handling: poll the click queue (data, not callbacks)
+        new Thread(() -> {
+            while (true) {
+                if (!data.gui.clicks.isEmpty()) {
+                    final String id = data.gui.clicks.remove(0);
+                    if ("quit".equals(id)) {
+                        shell.close();
+                        System.exit(0);
+                    }
+                }
+                try {
+                    Thread.sleep(50);
+                } catch (final InterruptedException e) {
+                    return;
+                }
+            }
+        }, "click-pump").start();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2demos/CoordinateSystemDemo.java b/src/main/java/eu/svjatoslav/aukio/e3d2demos/CoordinateSystemDemo.java
new file mode 100644 (file)
index 0000000..835b453
--- /dev/null
@@ -0,0 +1,101 @@
+package eu.svjatoslav.aukio.e3d2demos;
+
+import eu.svjatoslav.aukio.e3d2.app.AwtShell;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.shapes.Shapes;
+import eu.svjatoslav.aukio.e3d2.systems.CameraPass;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.LinePass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.gui.GuiRenderPass;
+import eu.svjatoslav.aukio.e3d2.systems.gui.SdfFont;
+
+/**
+ * Port of legacy CoordinateSystemDemo: three colored arrows from the
+ * origin (+X red, +Y green, +Z blue) on a reference grid, with labels.
+ *
+ * Built entirely on the e3d2 shape gallery (e3d2.shapes.Shapes):
+ * - SolidPolygonArrow -> Shapes.arrow (legacy proportions: tip radius
+ *   2.5x body, tip length 5x body; consistent outward single winding,
+ *   CullPass drops backfaces, translucent faces blend once).
+ * - Grid3D -> Shapes.grid (LineStore: glow, alpha fade at distance).
+ * - ForwardOrientedTextBlock -> LabelPass: screen-space SDF labels
+ *   anchored to the projected arrow tips (always facing the viewer,
+ *   distance-scaled unclamped, hidden behind the camera).
+ * - Arrow colors carry the legacy alpha B4 (71%): RasterPass
+ *   implements legacy's pass-2 translucency with painter's-algorithm
+ *   ordering.
+ * - Legacy is -Y up, e3d2 is +Y up: the +Y arrow points UP here — the
+ *   axis-to-color mapping is preserved, the world is right-side up.
+ *
+ * Deterministic: no time, no unseeded randomness.
+ */
+public final class CoordinateSystemDemo {
+
+    /** Length of each axis arrow (legacy ARROW_LENGTH). */
+    public static final double ARROW_LENGTH = 200;
+    /** Shaft radius (legacy BODY_RADIUS). */
+    private static final double BODY_RADIUS = 6;
+    /** Label distance past the arrow tip (legacy LABEL_OFFSET). */
+    public static final double LABEL_OFFSET = 20;
+    /** Legacy LineAppearance(1, hex("505064FF")). */
+    private static final int GRID_COLOR = 0xFF505064;
+    private static final double GRID_WIDTH = 1;
+    /** Legacy Grid3D(-300..300, step 50). */
+    private static final int GRID_EXTENT = 300;
+    private static final int GRID_STEP = 50;
+    /** Start camera: diagonal 3/4 view from -x/-z, above the grid —
+     *  all three axes read at comparable length (the legacy doc
+     *  screenshot was taken after flying, not at buildScene's pose,
+     *  so the pose is chosen for composition, not ported). */
+    private static final double START_X = -250, START_Y = 140, START_Z = -250;
+
+    public static void buildScene(final Substrate data) {
+        Shapes.grid(data, -GRID_EXTENT, -GRID_EXTENT, GRID_EXTENT,
+                GRID_EXTENT, 0, GRID_STEP, GRID_COLOR, GRID_WIDTH);
+        // legacy colors with alpha B4 (71%)
+        Shapes.arrow(data, 0, 0, 0, ARROW_LENGTH, 0, 0, BODY_RADIUS,
+                0xB4FF0000); // +X red,   legacy FF0000B4
+        Shapes.arrow(data, 0, 0, 0, 0, ARROW_LENGTH, 0, BODY_RADIUS,
+                0xB400FF00); // +Y green, legacy 00FF00B4
+        Shapes.arrow(data, 0, 0, 0, 0, 0, ARROW_LENGTH, BODY_RADIUS,
+                0xB40000FF); // +Z blue,  legacy 0000FFB4
+        // axis labels are NOT geometry: LabelPass (in the kernel, see
+        // main) overlays forward-oriented SDF text at the tips
+    }
+
+    /** The three axis labels, anchored just past the arrow tips. */
+    public static LabelPass labels() {
+        final double at = ARROW_LENGTH + LABEL_OFFSET;
+        return new LabelPass()
+                .add(at, 0, 0, "X", 0xFFFF4040)
+                .add(0, at, 0, "Y", 0xFF40FF40)
+                .add(0, 0, at, "Z", 0xFF9090FF);
+    }
+
+    public static void main(final String[] args) {
+        final int w = 800, h = 600;
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(w, h);
+        data.guiFont = SdfFont.build(48); // atlas for the axis labels
+        buildScene(data);
+        // aim at the origin: yaw = atan2(dx, dz), pitch = asin(dy/len)
+        final double len = Math.sqrt(START_X * START_X + START_Y * START_Y
+                + START_Z * START_Z);
+        data.camera.set(START_X, START_Y, START_Z,
+                Math.atan2(-START_X, -START_Z), Math.asin(-START_Y / len),
+                w / 2.5);
+        final Kernel kernel = new Kernel();
+        kernel.add(new CameraPass());
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.add(new LinePass());
+        kernel.add(labels());
+        kernel.add(new GuiRenderPass());
+        new AwtShell(data, kernel, "Coordinate System (Aukio 3D 2)").open();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2demos/HouseDemo.java b/src/main/java/eu/svjatoslav/aukio/e3d2demos/HouseDemo.java
new file mode 100644 (file)
index 0000000..6df056e
--- /dev/null
@@ -0,0 +1,296 @@
+package eu.svjatoslav.aukio.e3d2demos;
+
+import eu.svjatoslav.aukio.e3d2.app.AwtShell;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.shapes.Shapes;
+import eu.svjatoslav.aukio.e3d2.systems.CameraPass;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.gi.GiPass;
+
+/**
+ * Port of legacy HouseDemo: a walkable house interior with three rooms in a
+ * row (living room, hall, kitchen), each lit by a differently colored lamp,
+ * plus a dim green desk lamp in the hall. The point of the demo is GLOBAL
+ * ILLUMINATION in realtime: GiPass bakes per-texel lightmaps on background
+ * threads while you walk around — shadows, light falloff gradients and
+ * colored bounce converge live over the first seconds.
+ *
+ * Engine-difference adaptations (legacy -&gt; e3d2):
+ * - Legacy -Y up maps to e3d2 +Y up by negating y at the boundary.
+ * - Winding: CullPass keeps the face whose winding normal points AWAY from
+ *   the viewer, so surfaces are wound with the normal pointing INTO the
+ *   wall (opposite of legacy's shading-normal convention). GI bounce still
+ *   gathers toward the room: GiPass orients the hemisphere toward the lit
+ *   side (gen-0 litSide vote), independent of winding.
+ * - Subdivision is kept from legacy (~100x80-unit cells): GiPass bakes
+ *   0.5 units/texel clamped to 32 texels per triangle, so 100-unit cells
+ *   yield ~3 units/texel effective — the same density legacy used
+ *   (setLightmapUnitsPerTexel(3.0)).
+ * - Legacy LightSource(color, intensity): e3d2 LightStore folds intensity
+ *   into the color channels (r,g,b may exceed 255), and GiPass applies the
+ *   SAME attenuation curve as legacy (1/(1 + 0.0001·d²)), so the demo
+ *   passes the legacy colors×intensity verbatim — no tuning constants.
+ * - Ambient is the engine constant (40) instead of legacy's 0x181818 (24)
+ *   — named drift class "ambient handling".
+ *
+ * Deterministic scene; camera flies with CameraPass (WASD/arrows/mouse
+ * drag; SpaceMouse when present).
+ */
+public final class HouseDemo {
+
+    /** Wall height in world units. */
+    private static final double WALL_HEIGHT = 320;
+    /** Thickness of partition walls between rooms (two surfaces apart). */
+    private static final double PARTITION_THICKNESS = 10;
+    /** Doorway half-width in world units (full width 200). */
+    private static final double DOOR_HALF_WIDTH = 100;
+    /** Doorway height in world units. */
+    private static final double DOOR_HEIGHT = 240;
+
+    // House layout: three rooms side by side along X.
+    // X from -1200 to +1200, partitions at -400 and +400.
+    // Z from -350 to +350. Floor at Y=0, ceiling at Y=+320 (+Y up).
+    private static final double X_MIN = -1200;
+    private static final double X_MAX = 1200;
+    private static final double Z_MIN = -350;
+    private static final double Z_MAX = 350;
+    private static final double FLOOR_Y = 0;
+    private static final double CEIL_Y = WALL_HEIGHT;
+
+    private static final int WALL_COLOR = 0xFFD8D0C0;
+    private static final int FLOOR_COLOR = 0xFF989080;
+    private static final int CEILING_COLOR = 0xFFE8E8E8;
+
+    private HouseDemo() {
+    }
+
+    /** House geometry (walls, floors, ceilings, furniture), no lights. */
+    public static void buildScene(final Substrate data) {
+        buildHouse(data);
+        addFurniture(data);
+    }
+
+    /**
+     * One colored light per room, plus the dim green desk lamp — legacy
+     * positions (y negated), colors and intensities VERBATIM: intensity is
+     * folded into the color channels and GiPass applies legacy's
+     * attenuation curve, so no retuning is needed. When {@code markers} is
+     * set, each lamp gets a small colored cube BELOW its position (the
+     * LightSourceMarker port). The GI oracle passes false: markers near
+     * lamps shadow the bake differently per engine and would drown the GI
+     * comparison.
+     */
+    public static void addLights(final Substrate data, final boolean markers) {
+        addLight(data, -800, 240, 0, 0xFF, 0xD8, 0x90, 6.0, markers); // living, warm yellow
+        addLight(data, 0, 240, 0, 0xD8, 0xE4, 0xFF, 5.0, markers);    // hall, cool white
+        addLight(data, 800, 240, 0, 0xFF, 0xB0, 0x60, 6.0, markers);  // kitchen, warm orange
+        addLight(data, 250, 60, -250, 0x60, 0xFF, 0x90, 2.0, markers); // hall corner, green
+    }
+
+    private static void addLight(final Substrate data,
+                                 final double x, final double y, final double z,
+                                 final int r, final int g, final int b,
+                                 final double intensity, final boolean marker) {
+        data.lights.add(x, y, z, r * intensity, g * intensity, b * intensity);
+        if (marker) {
+            // The marker must NOT enclose the light point: any geometry
+            // around it swallows every shadow ray and blacks out the lamp
+            // (rays end inside the marker). Hang it 20 units below.
+            Shapes.cube(data, x, y - 20, z, 12, 0xFF000000 | (r << 16) | (g << 8) | b);
+        }
+    }
+
+    /** Builds floor, ceiling, exterior walls and the two partition walls. */
+    public static void buildHouse(final Substrate data) {
+        // Floor: visible from inside (above) — winding normal down.
+        addGridSurface(data,
+                X_MIN, FLOOR_Y, Z_MIN,
+                X_MAX - X_MIN, 0, 0, 0, 0, Z_MAX - Z_MIN,
+                24, 7, FLOOR_COLOR, 0, 1, 0, false);
+
+        // Ceiling: visible from below — winding normal up.
+        addGridSurface(data,
+                X_MIN, CEIL_Y, Z_MIN,
+                X_MAX - X_MIN, 0, 0, 0, 0, Z_MAX - Z_MIN,
+                24, 7, CEILING_COLOR, 0, -1, 0, false);
+
+        // Front wall (z = Z_MIN): visible from +Z (inside).
+        addGridSurface(data,
+                X_MIN, FLOOR_Y, Z_MIN,
+                X_MAX - X_MIN, 0, 0, 0, CEIL_Y - FLOOR_Y, 0,
+                24, 4, WALL_COLOR, 0, 0, 1, false);
+
+        // Back wall (z = Z_MAX): visible from -Z (inside).
+        addGridSurface(data,
+                X_MIN, FLOOR_Y, Z_MAX,
+                X_MAX - X_MIN, 0, 0, 0, CEIL_Y - FLOOR_Y, 0,
+                24, 4, WALL_COLOR, 0, 0, -1, false);
+
+        // West exterior wall (x = X_MIN): visible from +X (inside).
+        addGridSurface(data,
+                X_MIN, FLOOR_Y, Z_MIN,
+                0, 0, Z_MAX - Z_MIN, 0, CEIL_Y - FLOOR_Y, 0,
+                7, 4, WALL_COLOR, 1, 0, 0, false);
+
+        // East exterior wall (x = X_MAX): visible from -X (inside).
+        addGridSurface(data,
+                X_MAX, FLOOR_Y, Z_MIN,
+                0, 0, Z_MAX - Z_MIN, 0, CEIL_Y - FLOOR_Y, 0,
+                7, 4, WALL_COLOR, -1, 0, 0, false);
+
+        // Partition walls at x = -400 and x = +400, with a centered doorway.
+        // Each partition is two surfaces PARTITION_THICKNESS apart, one
+        // visible from each room (legacy wall-thickness convention).
+        addPartition(data, -400);
+        addPartition(data, 400);
+    }
+
+    /** Builds one partition wall (two thin surfaces with a doorway). */
+    private static void addPartition(final Substrate data, final double x) {
+        final double half = PARTITION_THICKNESS / 2;
+        // Surface facing the western room (visible from -X).
+        addGridSurface(data,
+                x - half, FLOOR_Y, Z_MIN,
+                0, 0, Z_MAX - Z_MIN, 0, CEIL_Y - FLOOR_Y, 0,
+                7, 4, WALL_COLOR, -1, 0, 0, true);
+        // Surface facing the eastern room (visible from +X).
+        addGridSurface(data,
+                x + half, FLOOR_Y, Z_MIN,
+                0, 0, Z_MAX - Z_MIN, 0, CEIL_Y - FLOOR_Y, 0,
+                7, 4, WALL_COLOR, 1, 0, 0, true);
+    }
+
+    /**
+     * Adds a flat surface subdivided into a grid of quads (two triangles
+     * per cell). Winding is chosen so the quad is VISIBLE from the
+     * {@code inward} side: CullPass keeps the winding whose normal points
+     * away from the viewer, so the winding normal must point OPPOSITE to
+     * {@code inward}.
+     *
+     * @param cx,cy,cz  the grid origin corner
+     * @param ux..uz    full edge vector along the first axis
+     * @param vx..vz    full edge vector along the second axis
+     * @param uSegs     number of cells along U
+     * @param vSegs     number of cells along V
+     * @param color     surface color (argb)
+     * @param inx..inz  direction the surface faces (visible side)
+     * @param hasDoorway if true, cells overlapping the centered doorway
+     *                   opening are skipped (door spans |U-center| &lt;
+     *                   DOOR_HALF_WIDTH, from V start up to DOOR_HEIGHT)
+     */
+    private static void addGridSurface(final Substrate data,
+                                       final double cx, final double cy, final double cz,
+                                       final double ux, final double uy, final double uz,
+                                       final double vx, final double vy, final double vz,
+                                       final int uSegs, final int vSegs,
+                                       final int color,
+                                       final double inx, final double iny, final double inz,
+                                       final boolean hasDoorway) {
+        final double du = 1.0 / uSegs;
+        final double dv = 1.0 / vSegs;
+        final double uLen = Math.sqrt(ux * ux + uy * uy + uz * uz);
+        final double vLen = Math.sqrt(vx * vx + vy * vy + vz * vz);
+
+        for (int i = 0; i < uSegs; i++) {
+            for (int j = 0; j < vSegs; j++) {
+                if (hasDoorway) {
+                    // Cell center distance from the wall centerline along U,
+                    // and cell base height along V, in world units.
+                    final double cellCenterU = (i + 0.5) * du * uLen;
+                    final double cellBaseV = j * dv * vLen;
+                    final boolean withinDoorWidth =
+                            Math.abs(cellCenterU - uLen / 2) < DOOR_HALF_WIDTH;
+                    final boolean withinDoorHeight = cellBaseV < DOOR_HEIGHT;
+                    if (withinDoorWidth && withinDoorHeight) {
+                        continue;
+                    }
+                }
+
+                final double f00u = i * du, f00v = j * dv;
+                final double f10u = (i + 1) * du, f10v = j * dv;
+                final double f11u = (i + 1) * du, f11v = (j + 1) * dv;
+                final double f01u = i * du, f01v = (j + 1) * dv;
+
+                // quad corners
+                final double p00x = cx + ux * f00u + vx * f00v;
+                final double p00y = cy + uy * f00u + vy * f00v;
+                final double p00z = cz + uz * f00u + vz * f00v;
+                final double p10x = cx + ux * f10u + vx * f10v;
+                final double p10y = cy + uy * f10u + vy * f10v;
+                final double p10z = cz + uz * f10u + vz * f10v;
+                final double p11x = cx + ux * f11u + vx * f11v;
+                final double p11y = cy + uy * f11u + vy * f11v;
+                final double p11z = cz + uz * f11u + vz * f11v;
+                final double p01x = cx + ux * f01u + vx * f01v;
+                final double p01y = cy + uy * f01u + vy * f01v;
+                final double p01z = cz + uz * f01u + vz * f01v;
+
+                // Winding normal of (p00, p10, p11) is (p10-p00) x (p11-p00).
+                // CullPass keeps the face whose normal points AWAY from the
+                // viewer: keep the order when the normal points opposite to
+                // the visible (inward) direction, flip otherwise.
+                final double e1x = p10x - p00x, e1y = p10y - p00y, e1z = p10z - p00z;
+                final double e2x = p11x - p00x, e2y = p11y - p00y, e2z = p11z - p00z;
+                final double nx = e1y * e2z - e1z * e2y;
+                final double ny = e1z * e2x - e1x * e2z;
+                final double nz = e1x * e2y - e1y * e2x;
+                final boolean normalAwayFromViewer = nx * inx + ny * iny + nz * inz < 0;
+
+                if (normalAwayFromViewer) {
+                    quad(data, color, p00x, p00y, p00z, p10x, p10y, p10z,
+                            p11x, p11y, p11z, p01x, p01y, p01z);
+                } else {
+                    quad(data, color, p00x, p00y, p00z, p01x, p01y, p01z,
+                            p11x, p11y, p11z, p10x, p10y, p10z);
+                }
+            }
+        }
+    }
+
+    /** One quad as two triangles: (a,b,c) + (a,c,d). */
+    private static void quad(final Substrate data, final int color,
+                             final double ax, final double ay, final double az,
+                             final double bx, final double by, final double bz,
+                             final double cx, final double cy, final double cz,
+                             final double dx, final double dy, final double dz) {
+        data.triangles.addTriangle(ax, ay, az, bx, by, bz, cx, cy, cz, color);
+        data.triangles.addTriangle(ax, ay, az, cx, cy, cz, dx, dy, dz, color);
+    }
+
+    /** Furniture cubes — legacy centers with y negated (sofa, table, counter). */
+    public static void addFurniture(final Substrate data) {
+        Shapes.cube(data, -800, 70, 150, 70, 0xFF8A4A3A);   // sofa, living room
+        Shapes.cube(data, 0, 60, -100, 60, 0xFF5A4A6A);     // table, hall
+        Shapes.cube(data, 800, 75, 100, 75, 0xFF3A5A4A);    // counter, kitchen
+    }
+
+    /** Default pose: from the kitchen looking west through both doorways,
+     *  all three rooms in a row. Aims level at the living-room center. */
+    public static void setDefaultPose(final Substrate data, final int width) {
+        final double px = 480.94, py = 130.17, pz = 126.27;
+        final double fx = -800 - px, fy = 130 - py, fz = 0 - pz;
+        final double fl = Math.sqrt(fx * fx + fy * fy + fz * fz);
+        data.camera.set(px, py, pz,
+                Math.atan2(fx, fz), Math.asin(fy / fl), width / 3.0);
+    }
+
+    public static void main(final String[] args) {
+        final int w = 800, h = 600;
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(w, h);
+        buildScene(data);
+        addLights(data, true);
+        setDefaultPose(data, w);
+        final Kernel kernel = new Kernel();
+        kernel.add(new CameraPass());
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.add(new GiPass(4, 1e9));
+        new AwtShell(data, kernel, "House (Aukio 3D 2)").open();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2demos/LabelPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2demos/LabelPass.java
new file mode 100644 (file)
index 0000000..15eaeee
--- /dev/null
@@ -0,0 +1,108 @@
+package eu.svjatoslav.aukio.e3d2demos;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.svjatoslav.aukio.e3d2.kernel.GuiStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Pass;
+import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.gui.GuiRenderPass;
+
+/**
+ * Forward-oriented text labels anchored to 3D points — the e3d2 answer
+ * to legacy ForwardOrientedTextBlock: instead of text GEOMETRY (a
+ * plaque quad facing the start pose), each label is screen-space SDF
+ * text anchored to a projected 3D point. Always faces the viewer,
+ * never mirrored, scales with distance like world geometry (UNCLAMPED
+ * — user decision 2026-10-13), hides when the anchor is behind the
+ * camera.
+ *
+ * Demo-local on purpose: generic 3D-anchored labels become an ENGINE
+ * feature when a consumer outside demos exists.
+ */
+public final class LabelPass implements Pass {
+
+    /** World size the label emulates at the anchor's depth. */
+    private static final double WORLD_SIZE = 30;
+    /** Hide closer than this camera-space z (near-plane margin). */
+    private static final double HIDE_Z = 5;
+
+    private static final class Entry {
+        double x, y, z;
+        String text;
+        int argb;
+        GuiStore.Label label;
+    }
+
+    private final List<Entry> entries = new ArrayList<>();
+
+    /** Adds a label anchored at world (x,y,z). Returns this (fluent). */
+    public LabelPass add(final double x, final double y, final double z,
+                         final String text, final int argb) {
+        final Entry e = new Entry();
+        e.x = x;
+        e.y = y;
+        e.z = z;
+        e.text = text;
+        e.argb = argb;
+        entries.add(e);
+        return this;
+    }
+
+    @Override
+    public int order() {
+        return 249; // with diag-hud, just before gui-render (250)
+    }
+
+    @Override
+    public String name() {
+        return "labels";
+    }
+
+    @Override
+    public void apply(final Substrate data) {
+        if (data.guiFont == null || data.image == null) {
+            return;
+        }
+        for (final Entry e : entries) {
+            if (e.label == null) {
+                e.label = new GuiStore.Label(0, 0, 0.2, e.argb, e.text);
+                data.gui.labels.add(e.label);
+            }
+            place(data, e);
+        }
+    }
+
+    /** Projects the anchor with exactly TransformPass's math, then
+     *  moves the label's screen position/scale. */
+    private void place(final Substrate data, final Entry e) {
+        final double cosY = Math.cos(data.camera.yaw);
+        final double sinY = Math.sin(data.camera.yaw);
+        final double cosP = Math.cos(data.camera.pitch);
+        final double sinP = Math.sin(data.camera.pitch);
+        final double dx = e.x - data.camera.x;
+        final double dy = e.y - data.camera.y;
+        final double dz = e.z - data.camera.z;
+        // inverse yaw about +Y, then pitch about +X (TransformPass)
+        final double x1 = dx * cosY - dz * sinY;
+        final double z1 = dx * sinY + dz * cosY;
+        final double cy1 = dy * cosP - z1 * sinP;
+        final double cz1 = dy * sinP + z1 * cosP;
+        if (cz1 < HIDE_Z) {
+            e.label.text = "";      // behind the near plane: hide
+            return;
+        }
+        e.label.text = e.text;
+        final double sx = data.image.width / 2.0 + (x1 / cz1) * data.camera.scale;
+        final double sy = data.image.height / 2.0 - (cy1 / cz1) * data.camera.scale;
+        // perspective scale, UNCLAMPED (user decision 2026-10-13):
+        // labels shrink to specks far away and blow up point-blank,
+        // exactly like world geometry
+        final double s = WORLD_SIZE * data.camera.scale
+                / (cz1 * SdfFontStore.CELL);
+        e.label.scale = s;
+        e.label.x = (int) (sx - GuiRenderPass.textWidth(data.guiFont, e.text, s) / 2.0);
+        e.label.y = (int) (sy + SdfFontStore.CELL * s * 0.25); // baseline ~centered
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2demos/MinimalExampleMain.java b/src/main/java/eu/svjatoslav/aukio/e3d2demos/MinimalExampleMain.java
new file mode 100644 (file)
index 0000000..5d232f2
--- /dev/null
@@ -0,0 +1,92 @@
+package eu.svjatoslav.aukio.e3d2demos;
+
+import eu.svjatoslav.aukio.e3d2.app.AwtShell;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CameraPass;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * Port of legacy MinimalExample: one red box (-50..50), camera pulled
+ * back. Legacy coordinates (-Y up) map to e3d2 (+Y up) by negating y —
+ * the box is symmetric, so only the camera translation changes.
+ *
+ * Default: opens the interactive window (like the legacy ViewFrame —
+ * WASD/QE fly, mouse looks). Arg "shot": headless one-frame render to
+ * target/minimal-example.png for the oracle harness.
+ *
+ * Deterministic: no time, no unseeded randomness (golden harness rule,
+ * same as legacy).
+ */
+public final class MinimalExampleMain {
+
+    public static void buildScene(final Substrate data) {
+        final int red = 0xFFFF0000;
+        // 12 triangles of the box (outward windings)
+        quad(data, -50, -50, 50, 50, -50, 50, 50, 50, 50, -50, 50, 50, red);    // front
+        quad(data, 50, -50, -50, -50, -50, -50, -50, 50, -50, 50, 50, -50, red); // back
+        quad(data, -50, 50, 50, 50, 50, 50, 50, 50, -50, -50, 50, -50, red);    // top
+        quad(data, -50, -50, -50, 50, -50, -50, 50, -50, 50, -50, -50, 50, red); // bottom
+        quad(data, 50, -50, 50, 50, -50, -50, 50, 50, -50, 50, 50, 50, red);    // right
+        quad(data, -50, -50, -50, -50, -50, 50, -50, 50, 50, -50, 50, -50, red); // left
+    }
+
+    private static void quad(final Substrate d,
+                             final double x0, final double y0, final double z0,
+                             final double x1, final double y1, final double z1,
+                             final double x2, final double y2, final double z2,
+                             final double x3, final double y3, final double z3,
+                             final int color) {
+        d.triangles.addTriangle(x0, y0, z0, x1, y1, z1, x2, y2, z2, color);
+        d.triangles.addTriangle(x0, y0, z0, x2, y2, z2, x3, y3, z3, color);
+    }
+
+    private static Kernel pipeline() {
+        final Kernel kernel = new Kernel();
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        return kernel;
+    }
+
+    private static Substrate scene(final int w, final int h) {
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(w, h);
+        buildScene(data);
+        // legacy (0,-100,-300) in -Y-up == e3d2 (0,100,-300); look at origin:
+        // pitch = asin(-100/hypot(100,300)) = -0.322, yaw 0; scale = w/3
+        data.camera.set(0, 100, -300, 0, Math.asin(-100.0 / Math.hypot(100, 300)),
+                w / 3.0);
+        return data;
+    }
+
+    public static void main(final String[] args) throws Exception {
+        final int w = 640, h = 480;
+        if (args.length > 0 && args[0].equals("shot")) {
+            final Substrate data = scene(w, h);
+            data.image.clear(0xFF102030);
+            pipeline().runOnce(data);
+            final File out = new File("target/minimal-example.png");
+            out.getParentFile().mkdirs();
+            final BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
+            img.setRGB(0, 0, w, h, data.image.pixels, 0, w);
+            ImageIO.write(img, "png", out);
+            System.out.println("wrote " + out.getAbsolutePath());
+            return;
+        }
+        final Substrate data = scene(w, h);
+        final Kernel kernel = new Kernel();
+        kernel.add(new CameraPass());
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        new AwtShell(data, kernel, "Minimal example (Aukio 3D 2)").open();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2demos/RGBCubeDemo.java b/src/main/java/eu/svjatoslav/aukio/e3d2demos/RGBCubeDemo.java
new file mode 100644 (file)
index 0000000..3d2b4b1
--- /dev/null
@@ -0,0 +1,125 @@
+package eu.svjatoslav.aukio.e3d2demos;
+
+import eu.svjatoslav.aukio.e3d2.app.AwtShell;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.systems.CameraPass;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+
+/**
+ * Port of legacy RGBCubeDemo: a 10x10x10 grid of square plates on flat
+ * XY planes; grid position maps to color channel (x -> R, y -> G,
+ * z -> B), so flying around explores a volumetric RGB color cube.
+ * Each plate is textured solid-color with a black border (legacy
+ * TextureGenerator.solidWithBorder: 32 px texture, 3 px border).
+ *
+ * Engine-difference adaptations:
+ * - CullPass culls by winding with no per-triangle switch, so each
+ *   plate is emitted in BOTH windings (reversed duplicates make it
+ *   two-sided, replacing legacy setBackfaceCulling(false)).
+ * - RasterPass multiplies texel by the triangle's flat color, so the
+ *   base color is WHITE (identity) and the texture carries the color.
+ * - Legacy aukio-3d is -Y up, e3d2 is +Y up: y is negated at the
+ *   boundary. The grid is symmetric in y so only the camera pose
+ *   changes; the camera sits at the legacy position (mapped) and is
+ *   AIMED at the cube center (the legacy angles were hand-tuned).
+ *
+ * Deterministic: no time, no unseeded randomness.
+ */
+public final class RGBCubeDemo {
+
+    /** Number of plates per dimension. */
+    private static final int GRID_SIZE = 10;
+    /** Size of each plate in world units. */
+    private static final double SQUARE_SIZE = 40;
+    /** Spacing between plate centers (20% gap, as legacy). */
+    private static final double SPACING = SQUARE_SIZE * 1.25;
+    /** Centering offset. */
+    private static final double HALF_GRID = GRID_SIZE / 2.0;
+    /** Texture size in pixels (legacy TEXTURE_SIZE). */
+    private static final int TEXTURE_SIZE = 32;
+    /** Black border width in pixels (legacy BORDER_WIDTH). */
+    private static final int BORDER = 3;
+
+    public static void buildScene(final Substrate data) {
+        final double maxIndex = GRID_SIZE - 1;
+        for (int gz = 0; gz < GRID_SIZE; gz++) {
+            for (int gy = 0; gy < GRID_SIZE; gy++) {
+                for (int gx = 0; gx < GRID_SIZE; gx++) {
+                    final double x = (gx - HALF_GRID) * SPACING + SPACING / 2;
+                    // -Y-up legacy -> +Y-up e3d2: negate y
+                    final double y = -((gy - HALF_GRID) * SPACING + SPACING / 2);
+                    final double z = (gz - HALF_GRID) * SPACING + SPACING / 2;
+                    final int rgb = ((int) (gx / maxIndex * 255) << 16)
+                            | ((int) (gy / maxIndex * 255) << 8)
+                            | (int) (gz / maxIndex * 255);
+                    plate(data, x, y, z, solidWithBorder(data, rgb));
+                }
+            }
+        }
+    }
+
+    /** Legacy TextureGenerator.solidWithBorder: fill with a black frame. */
+    private static int solidWithBorder(final Substrate data, final int rgb) {
+        final int[] px = new int[TEXTURE_SIZE * TEXTURE_SIZE];
+        for (int ty = 0; ty < TEXTURE_SIZE; ty++) {
+            for (int tx = 0; tx < TEXTURE_SIZE; tx++) {
+                final boolean border = tx < BORDER || ty < BORDER
+                        || tx >= TEXTURE_SIZE - BORDER || ty >= TEXTURE_SIZE - BORDER;
+                px[ty * TEXTURE_SIZE + tx] = border ? 0xFF000000 : 0xFF000000 | rgb;
+            }
+        }
+        return data.textures.add(TEXTURE_SIZE, TEXTURE_SIZE, px);
+    }
+
+    /** One textured plate, both windings (two-sided). Base color white:
+     *  RasterPass multiplies texel by base color, so white = identity. */
+    private static void plate(final Substrate d, final double x,
+                              final double y, final double z, final int tex) {
+        final double h = SQUARE_SIZE / 2;
+        final double x0 = x - h, x1 = x + h, y0 = y - h, y1 = y + h;
+        // visible from -z (winding proven by MinimalExampleMain's front
+        // face): (ll,lr,ur),(ll,ur,ul); uv: u right, v down
+        tri(d, tex, x0, y0, z, 0, 1,  x1, y0, z, 1, 1,  x1, y1, z, 1, 0);
+        tri(d, tex, x0, y0, z, 0, 1,  x1, y1, z, 1, 0,  x0, y1, z, 0, 0);
+        // visible from +z: reversed winding, same uv on the same corners
+        tri(d, tex, x0, y1, z, 0, 0,  x1, y1, z, 1, 0,  x1, y0, z, 1, 1);
+        tri(d, tex, x0, y1, z, 0, 0,  x1, y0, z, 1, 1,  x0, y0, z, 0, 1);
+    }
+
+    /** One triangle of a plate, with uv. */
+    private static void tri(final Substrate d, final int tex,
+                             final double ax, final double ay, final double az,
+                             final double au, final double av,
+                             final double bx, final double by, final double bz,
+                             final double bu, final double bv,
+                             final double cx, final double cy, final double cz,
+                             final double cu, final double cv) {
+        d.triangles.addTriangle(ax, ay, az, bx, by, bz, cx, cy, cz,
+                0xFFFFFFFF, au, av, bu, bv, cu, cv, tex);
+    }
+
+    public static void main(final String[] args) {
+        final int w = 800, h = 600;
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(w, h);
+        buildScene(data);
+        // Legacy camera position (-405.73, -407.15, -713.60) with y
+        // negated; aimed exactly at the cube center (origin):
+        // yaw = atan2(dx, dz), pitch = asin(dy/len) for direction
+        // (dx, dy, dz) = -position (yaw 0 = +Z, +yaw right, +pitch up).
+        final double px = -405.73, py = 407.15, pz = -713.60;
+        final double len = Math.sqrt(px * px + py * py + pz * pz);
+        data.camera.set(px, py, pz,
+                Math.atan2(-px, -pz), Math.asin(-py / len), w / 3.0);
+        final Kernel kernel = new Kernel();
+        kernel.add(new CameraPass());
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        new AwtShell(data, kernel, "RGB Cube (Aukio 3D 2)").open();
+    }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2demos/ShapesGalleryDemo.java b/src/main/java/eu/svjatoslav/aukio/e3d2demos/ShapesGalleryDemo.java
new file mode 100644 (file)
index 0000000..e626f1d
--- /dev/null
@@ -0,0 +1,71 @@
+package eu.svjatoslav.aukio.e3d2demos;
+
+import eu.svjatoslav.aukio.e3d2.app.AwtShell;
+import eu.svjatoslav.aukio.e3d2.kernel.ImageStore;
+import eu.svjatoslav.aukio.e3d2.kernel.Kernel;
+import eu.svjatoslav.aukio.e3d2.kernel.Substrate;
+import eu.svjatoslav.aukio.e3d2.shapes.Shapes;
+import eu.svjatoslav.aukio.e3d2.systems.CameraPass;
+import eu.svjatoslav.aukio.e3d2.systems.CullPass;
+import eu.svjatoslav.aukio.e3d2.systems.LinePass;
+import eu.svjatoslav.aukio.e3d2.systems.RasterPass;
+import eu.svjatoslav.aukio.e3d2.systems.TransformPass;
+import eu.svjatoslav.aukio.e3d2.systems.gui.GuiRenderPass;
+import eu.svjatoslav.aukio.e3d2.systems.gui.SdfFont;
+
+/**
+ * Shape gallery: one of each e3d2.shapes.Shapes solid in a row on the
+ * reference grid, with forward-oriented labels. The e3d2 counterpart
+ * of legacy's composite-shape collection (SolidPolygonArrow, Cube,
+ * Cone, Cylinder, Pyramid, Sphere, Grid3D).
+ *
+ * Deterministic: no time, no unseeded randomness.
+ */
+public final class ShapesGalleryDemo {
+
+    private static final int GRID_COLOR = 0xFF505064;
+    private static final double START_X = -60, START_Y = 160, START_Z = -420;
+
+    public static void buildScene(final Substrate data) {
+        Shapes.grid(data, -400, -200, 400, 200, 0, 50, GRID_COLOR, 1);
+        Shapes.cube(data, -300, 30, 0, 60, 0xFFB0B040);
+        Shapes.sphere(data, -180, 35, 0, 35, 16, 10, 0xFF40C8FF);
+        Shapes.cylinder(data, -60, 0, 0, 0, 70, 0, 25, 12, 0xFFFFC040);
+        Shapes.cone(data, 60, 0, 0, 0, 70, 0, 30, 12, 0xFFFF60C0);
+        Shapes.pyramid(data, 180, 0, 0, 32, 70, 0xFFC0FF60);
+        Shapes.arrow(data, 300, 0, 0, 0, 70, 0, 10, 0xFFFF5040);
+    }
+
+    /** One label above each shape. */
+    public static LabelPass labels() {
+        return new LabelPass()
+                .add(-300, 85, 0, "cube", 0xFFB0FFB0)
+                .add(-180, 85, 0, "sphere", 0xFF40C8FF)
+                .add(-60, 85, 0, "cylinder", 0xFFFFC040)
+                .add(60, 85, 0, "cone", 0xFFFF60C0)
+                .add(180, 85, 0, "pyramid", 0xFFC0FF60)
+                .add(300, 85, 0, "arrow", 0xFFFF5040);
+    }
+
+    public static void main(final String[] args) {
+        final int w = 1024, h = 600;
+        final Substrate data = new Substrate();
+        data.image = new ImageStore(w, h);
+        data.guiFont = SdfFont.build(48);
+        buildScene(data);
+        final double len = Math.sqrt(START_X * START_X + START_Y * START_Y
+                + START_Z * START_Z);
+        data.camera.set(START_X, START_Y, START_Z,
+                Math.atan2(-START_X, -START_Z), Math.asin(-START_Y / len),
+                w / 2.5);
+        final Kernel kernel = new Kernel();
+        kernel.add(new CameraPass());
+        kernel.add(new TransformPass());
+        kernel.add(new CullPass());
+        kernel.add(new RasterPass());
+        kernel.add(new LinePass());
+        kernel.add(labels());
+        kernel.add(new GuiRenderPass());
+        new AwtShell(data, kernel, "Shape Gallery (Aukio 3D 2)").open();
+    }
+}