From: Svjatoslav Agejenko Date: Sat, 24 Oct 2026 11:31:16 +0000 (+0300) Subject: feat(gui): add 3D-anchored label pass X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;ds=inline;p=aukio-3d-2.git feat(gui): add 3D-anchored label pass Move the world-anchored SDF label implementation into systems.gui so engine consumers share one capability. Extract SdfTextMetrics to avoid pass-to-pass coupling, add focused behavior coverage, and document the new pass and current verification results. --- diff --git a/Documentation/index.org b/Documentation/index.org index 6fdcc01..c43beb2 100644 --- a/Documentation/index.org +++ b/Documentation/index.org @@ -62,9 +62,9 @@ section updated at slice end. *Rename criteria:* all slices done OR consciously dropped, consumers running, FO4 exam green. -*Current verified state (2026-10-21):* 132/132 tests green under +*Current verified state (2026-10-24):* 142/142 tests green under =xvfb-run -a=; flat temple oracle 688/307200 px (0.224%, meanAbs 0.19); -GI oracle 37541 px (12.220%, meanAbs 11.62), both engines converged, +GI oracle 36193 px (11.782%, meanAbs 11.62), both engines converged, drift in named classes only. Numbers quoted inside the progress log below are the state at the time each entry was written — this line is the current one. @@ -82,7 +82,7 @@ Maven project, Java 21 (=--release 21=), package | =kernel/= | the fixed pipeline (=Kernel=, =Pass=, =Parallel=) and every substrate store: MeshStore, ScreenStore, ImageStore, CameraStore, LightStore, TextureStore, BvhStore, ChunkStore, InputStore, GuiStore, SdfFontStore, GiStore, LightmapStore, LineStore | | =systems/= | one pass per file: Transform, Cull, Raster, Fog, Line, ChunkFrustumCull, WorldStreamer, Camera, SpaceMouse, StereoMerge | | =systems/gi/= | GiPass (lifecycle + radiometry), GiScheduler, Raycast, EntryWork | -| =systems/gui/= | GuiPass, GuiRenderPass, SdfFont, DiagnosticsHudPass | +| =systems/gui/= | GuiPass, GuiRenderPass, LabelPass, SdfFont, SdfTextMetrics, DiagnosticsHudPass | | =shapes/= | the geometry gallery: cube, sphere, cylinder, cone, arrow, pyramid, grid | | =app/= | AwtShell, DeveloperToolsWindow, BugReport, PersistentLog, SpaceNavigatorHid, SpaceMouseManager | | =oracle/= | compare.sh, gi-compare.sh, OracleTemple, GiOracle | @@ -342,7 +342,7 @@ order is the table below. :CUSTOM_ID: pass-order :END: -Measured from code (2026-10-21): +Measured from the current code: | order | pass | job | |-------+--------------------+------------------------------------------------------------| @@ -357,6 +357,7 @@ Measured from code (2026-10-21): | 40 | LinePass | world-space lines | | 40 | StereoMergePass | anaglyph / side-by-side merge | | 90 | FogPass | exponential distance fog, post-process | +| 249 | LabelPass | viewer-facing SDF labels anchored to 3D points | | 249 | DiagnosticsHudPass | fps / tris / chunks / camera readout | | 250 | GuiRenderPass | panels, labels, SDF text | @@ -701,6 +702,15 @@ plus 2 px for the AA ramp (full-cell quads overlap at advance width and double-blend into blobs), and advance the pen even for out-of-range characters. +=LabelPass= (order 249) maintains viewer-facing labels anchored to 3D +world points. Applications explicitly register an instance and use +=add(x, y, z, text, argb)= to supply their labels. Each frame it projects +the anchors with the camera transform, centres the text, scales it with +distance without a clamp, and hides anchors closer than camera-space +=z=5. The pass owns only its configured =GuiStore.Label= records; it is +not auto-discovered. =SdfTextMetrics= shares string-width measurement +between the GUI systems without one pass referencing another. + * Platform :PROPERTIES: :CUSTOM_ID: platform @@ -2433,3 +2443,24 @@ md5-identical either way (=house-poc.png=). Engine suite 137/137; flat temple oracle unchanged 688 px / 0.224% / meanAbs 0.19; GI oracle meanAbs 11.62 (pixel share 11.78-11.82%, run-to-run noise). The HouseDemo now becomes visible without any input. + +** 3D-anchored labels join the engine (2026-10-24) +:PROPERTIES: +:CUSTOM_ID: engine-label-pass +:END: + +=LabelPass= moved out of demos-2 into =systems.gui=: world-anchored +labels are an engine capability, while demos retain only their concrete +label lists. Moving it exposed a contract violation hidden by the old +location: the pass called =GuiRenderPass.textWidth=. Width measurement +now lives in the non-pass =SdfTextMetrics= helper, shared by both GUI +systems, so the no-pass-references-pass rule holds again. + +The projection, scaling, hiding and text-width arithmetic is unchanged. +New =LabelPassTest= coverage pins projection and label reuse, near-plane +hiding/restoration, absent font/image, and unclamped distance scaling; +=SdfGuiTest= also pins width measurement. Verified 142/142 tests under +=xvfb-run -a=, clean engine install and clean demos-2 package, and jar +contents with the old demos class absent. Flat temple oracle: 688 px +(0.224%, meanAbs 0.19). GI oracle: both engines converged, 36193 px +(11.782%, meanAbs 11.62). diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java index e0a788b..8ee8375 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/GuiRenderPass.java @@ -33,7 +33,8 @@ public final class GuiRenderPass implements Pass { b.x, b.y, b.w, b.h, b.hover ? b.hoverArgb : b.argb); if (data.guiFont != null) { final double s = (b.h - 10.0) / SdfFontStore.CELL; - final int textW = textWidth(data.guiFont, b.text, s); + final int textW = SdfTextMetrics.textWidth( + data.guiFont, b.text, s); drawText(data, b.x + (b.w - textW) / 2, b.y + b.h / 2 + (int) (data.guiFont.baseline * s / 2), s, b.textArgb, b.text); @@ -46,20 +47,6 @@ public final class GuiRenderPass implements Pass { } } - /** Pixel width of a string at scale s (atlas advances are in cell px). */ - public static int textWidth(final SdfFontStore font, final String text, - final double s) { - int w = 0; - for (int i = 0; i < text.length(); i++) { - final char c = text.charAt(i); - if (c < SdfFontStore.FIRST || c > SdfFontStore.LAST) { - continue; - } - w += (int) (font.advance[c - SdfFontStore.FIRST] * s); - } - return w; - } - private static int clamp(final int v, final int lo, final int hi) { return v < lo ? lo : Math.min(v, hi); } diff --git a/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPass.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPass.java new file mode 100644 index 0000000..c57d3df --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPass.java @@ -0,0 +1,109 @@ +package eu.svjatoslav.aukio.e3d2.systems.gui; + +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; + +/** + * 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. + * + * Applications configure instances with world anchors, text and colours; + * each instance maintains only its own GuiStore.Label records and is + * registered explicitly by the application. + */ +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 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 - SdfTextMetrics.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/e3d2/systems/gui/SdfTextMetrics.java b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfTextMetrics.java new file mode 100644 index 0000000..91d229a --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfTextMetrics.java @@ -0,0 +1,24 @@ +package eu.svjatoslav.aukio.e3d2.systems.gui; + +import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore; + +/** Text measurements shared by GUI systems without coupling passes. */ +final class SdfTextMetrics { + + private SdfTextMetrics() { + } + + /** Pixel width of a string at scale s (atlas advances are in cell px). */ + static int textWidth(final SdfFontStore font, final String text, + final double s) { + int w = 0; + for (int i = 0; i < text.length(); i++) { + final char c = text.charAt(i); + if (c < SdfFontStore.FIRST || c > SdfFontStore.LAST) { + continue; + } + w += (int) (font.advance[c - SdfFontStore.FIRST] * s); + } + return w; + } +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPassTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPassTest.java new file mode 100644 index 0000000..19cd1dc --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/LabelPassTest.java @@ -0,0 +1,94 @@ +package eu.svjatoslav.aukio.e3d2.systems.gui; + +import eu.svjatoslav.aukio.e3d2.kernel.GuiStore; +import eu.svjatoslav.aukio.e3d2.kernel.ImageStore; +import eu.svjatoslav.aukio.e3d2.kernel.SdfFontStore; +import eu.svjatoslav.aukio.e3d2.kernel.Substrate; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** World-anchored SDF labels. */ +class LabelPassTest { + + private static Substrate data() { + final Substrate data = new Substrate(); + data.image = new ImageStore(200, 100); + data.guiFont = SdfFont.build(48); + data.camera.set(0, 0, 0, 0, 0, 100); + return data; + } + + @Test + void projectsWorldAnchorAndReusesGuiLabel() { + final Substrate data = data(); + final LabelPass pass = new LabelPass() + .add(0, 0, 100, "X", 0xFF112233); + + pass.apply(data); + + assertEquals(1, data.gui.labels.size()); + final GuiStore.Label label = data.gui.labels.get(0); + final double scale = 30.0 * 100 / (100 * SdfFontStore.CELL); + final int width = (int) (data.guiFont.advance['X' - SdfFontStore.FIRST] + * scale); + assertEquals("X", label.text); + assertEquals(0xFF112233, label.argb); + assertEquals(scale, label.scale, 1e-9); + assertEquals((int) (100 - width / 2.0), label.x); + assertEquals((int) (50 + SdfFontStore.CELL * scale * 0.25), label.y); + + pass.apply(data); + assertEquals(1, data.gui.labels.size(), "second frame adds no duplicate"); + assertSame(label, data.gui.labels.get(0), "existing label is updated"); + } + + @Test + void hidesBehindNearPlaneAndRestoresWhenVisible() { + final Substrate data = data(); + final LabelPass pass = new LabelPass().add(0, 0, 4, "X", 0xFFFFFFFF); + + pass.apply(data); + assertEquals(1, data.gui.labels.size()); + assertEquals("", data.gui.labels.get(0).text); + + data.camera.set(0, 0, -96, 0, 0, 100); + pass.apply(data); + assertEquals("X", data.gui.labels.get(0).text); + } + + @Test + void missingFontOrImageLeavesGuiUntouched() { + final Substrate data = new Substrate(); + final LabelPass pass = new LabelPass().add(0, 0, 100, "X", 0xFFFFFFFF); + + pass.apply(data); + assertTrue(data.gui.labels.isEmpty(), "no font or image"); + + data.guiFont = SdfFont.build(48); + pass.apply(data); + assertTrue(data.gui.labels.isEmpty(), "no image"); + + data.guiFont = null; + data.image = new ImageStore(200, 100); + pass.apply(data); + assertTrue(data.gui.labels.isEmpty(), "no font"); + } + + @Test + void distantLabelsShrinkWithoutClamp() { + final Substrate data = data(); + final LabelPass pass = new LabelPass() + .add(0, 0, 100, "near", 0xFFFFFFFF) + .add(0, 0, 1000, "far", 0xFFFFFFFF); + + pass.apply(data); + + final double nearScale = data.gui.labels.get(0).scale; + final double farScale = data.gui.labels.get(1).scale; + assertEquals(10.0, nearScale / farScale, 1e-9); + assertTrue(farScale < 0.1, "far label is not clamped to a minimum size"); + } +} diff --git a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java index 32640d9..8d46088 100644 --- a/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java +++ b/src/test/java/eu/svjatoslav/aukio/e3d2/systems/gui/SdfGuiTest.java @@ -60,6 +60,15 @@ class SdfGuiTest { assertEquals(1, data.gui.clicks.size(), "no click on release outside"); } + @Test + void textWidthUsesPerGlyphAtlasAdvances() { + final SdfFontStore font = new SdfFontStore(); + font.advance['A' - SdfFontStore.FIRST] = 20; + font.advance['B' - SdfFontStore.FIRST] = 10; + + assertEquals(25, SdfTextMetrics.textWidth(font, "ABA", 0.5)); + } + @Test void sdfTextRendersWithAntialiasedEdges() { final Substrate data = new Substrate();