:ID: 1d942e3b-6071-440f-bd9b-876c8f7c53de
:END:
-| Path | Topic |
-|----------------------------------------------+---------------------------------------------------------------------|
-| ~Documentation/index.org~ | Main: coordinate system, shapes, CSG, developer tools |
-| ~Documentation/Rendering loop/index.org~ | 5-phase pipeline, multi-threaded paint |
-| ~Documentation/Shading/index.org~ | Lambert shading, lights, distance attenuation |
-| ~Documentation/CSG/index.org~ | Boolean ops via BSP trees |
-| ~Documentation/Frustum culling/index.org~ | View frustum culling |
-| ~Documentation/Near plane clip/index.org~ | Near-plane polygon clipping (straddling geometry) |
-| ~Documentation/Global illumination/index.org~ | Progressive GI: lightmaps, bounces, convergence |
-| ~Documentation/Perspective correct textures/index.org~ | Texture mapping math |
-| ~Documentation/Stereoscopic rendering/index.org~ | Side-by-side stereo: two passes, per-eye viewports, IPD |
-| ~Documentation/Depth buffer/index.org~ | Two-pass z-buffer, zw, depth margin, Hi-Z pyramid, determinism |
-| ~Documentation/SDF textures/index.org~ | SDF text: glyph fields, coverage window, TextCanvas |
+| Path | Topic |
+|--------------------------------------------------------+---------------------------------------------------------------------------|
+| ~Documentation/index.org~ | Main: coordinate system, shapes, CSG, developer tools |
+| ~Documentation/Rendering loop/index.org~ | 5-phase pipeline, multi-threaded paint |
+| ~Documentation/Shading/index.org~ | Lambert shading, lights, distance attenuation |
+| ~Documentation/CSG/index.org~ | Boolean ops via BSP trees |
+| ~Documentation/Frustum culling/index.org~ | View frustum culling |
+| ~Documentation/Near plane clip/index.org~ | Near-plane polygon clipping (straddling geometry) |
+| ~Documentation/Global illumination/index.org~ | Progressive GI: lightmaps, bounces, convergence |
+| ~Documentation/Perspective correct textures/index.org~ | Texture mapping math |
+| ~Documentation/Stereoscopic rendering/index.org~ | Side-by-side stereo: two passes, IPD, XR glasses head tracking |
+| ~Documentation/SpaceMouse 6DOF/index.org~ | 3Dconnexion SpaceNavigator: 6DOF cap camera drive, hot-plug |
+| ~Documentation/Depth buffer/index.org~ | Two-pass z-buffer, zw, depth margin, Hi-Z pyramid, determinism |
+| ~Documentation/SDF textures/index.org~ | SDF text: glyph fields, coverage window, TextCanvas |
+| ~Documentation/Octree ray tracing/index.org~ | Voxel octree (flat cell pool) + per-pixel ray tracer with shadows |
+| ~Documentation/Configuration/index.org~ | Shared ~/.config/aukio/config.yaml: e3d keys, -D overrides, atomic writes |
+| ~Documentation/GUI components/index.org~ | Interactive 3D widgets: focus, mouse picking, texture-UV clicks |
+| ~Documentation/Subpixel culling/index.org~ | Drop sub-pixel shapes in transform; cached verdict epoch |
Regenerate all HTML: ~Documentation/export-docs.sh~ (add ~--check~ for rendered
screenshots of every page).
--- /dev/null
+<svg viewBox="0 0 720 260" width="720" height="260" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ <marker id="aw-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#40b0d0"/>
+ </marker>
+ </defs>
+
+ <rect width="720" height="260" fill="#061018"/>
+
+ <text x="360" y="28" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">Writes: lock, re-read, merge, atomic rename</text>
+
+ <g font-family="monospace">
+ <rect x="13" y="60" width="104" height="56" rx="5" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="65" y="84" fill="#40b0d0" font-size="9" text-anchor="middle">setString</text>
+ <text x="65" y="100" fill="#999" font-size="8" text-anchor="middle">(app or UI)</text>
+
+ <rect x="131" y="60" width="104" height="56" rx="5" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="183" y="84" fill="#40b0d0" font-size="9" text-anchor="middle">same-JVM</text>
+ <text x="183" y="100" fill="#999" font-size="8" text-anchor="middle">writeMonitor</text>
+
+ <rect x="249" y="60" width="104" height="56" rx="5" fill="rgba(221,153,0,0.08)" stroke="#dd9900" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="301" y="84" fill="#dd9900" font-size="9" text-anchor="middle">sidecar lock</text>
+ <text x="301" y="100" fill="#999" font-size="8" text-anchor="middle"><file>.lock</text>
+
+ <rect x="367" y="60" width="104" height="56" rx="5" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="419" y="84" fill="#40b0d0" font-size="9" text-anchor="middle">fresh re-read</text>
+ <text x="419" y="100" fill="#999" font-size="8" text-anchor="middle">under the lock</text>
+
+ <rect x="485" y="60" width="104" height="56" rx="5" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="537" y="84" fill="#40b0d0" font-size="9" text-anchor="middle">merge key</text>
+ <text x="537" y="100" fill="#999" font-size="8" text-anchor="middle">into YAML</text>
+
+ <rect x="603" y="60" width="104" height="56" rx="5" fill="rgba(57,255,20,0.08)" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="655" y="84" fill="#39FF14" font-size="9" text-anchor="middle">tmp + atomic</text>
+ <text x="655" y="100" fill="#999" font-size="8" text-anchor="middle">rename</text>
+
+ <line x1="118" y1="88" x2="128" y2="88" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#aw-arr)"/>
+ <line x1="236" y1="88" x2="246" y2="88" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#aw-arr)"/>
+ <line x1="354" y1="88" x2="364" y2="88" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#aw-arr)"/>
+ <line x1="472" y1="88" x2="482" y2="88" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#aw-arr)"/>
+ <line x1="590" y1="88" x2="600" y2="88" stroke="#40b0d0" stroke-width="1.2" marker-end="url(#aw-arr)"/>
+ </g>
+
+ <text x="60" y="156" fill="#999" font-size="9" font-family="monospace">the FileLock serializes cooperating JVMs; the same-JVM monitor serializes threads</text>
+ <text x="60" y="174" fill="#999" font-size="9" font-family="monospace">a hand edit made between the writer's read and write survives the merge</text>
+ <text x="60" y="192" fill="#999" font-size="9" font-family="monospace">unknown keys and other sections are preserved verbatim</text>
+
+ <text x="60" y="228" fill="#c05088" font-size="9" font-family="monospace">caveat: SnakeYAML rewrites the file on the first set - human-written header comments are lost</text>
+ <text x="60" y="246" fill="#c05088" font-size="9" font-family="monospace">caveat: an editor saving a stale buffer over newer YAML still clobbers app writes</text>
+</svg>
--- /dev/null
+<svg viewBox="0 0 720 260" width="720" height="260" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ <marker id="cfg-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#999"/>
+ </marker>
+ </defs>
+
+ <rect width="720" height="260" fill="#061018"/>
+
+ <text x="360" y="30" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">Three layers; the topmost one that has the key wins</text>
+
+ <!-- fall-through arrow -->
+ <line x1="46" y1="66" x2="46" y2="186" stroke="#999" stroke-width="1.2" marker-end="url(#cfg-arr)"/>
+ <text x="58" y="62" fill="#999" font-size="8" font-family="monospace">key missing</text>
+ <text x="58" y="74" fill="#999" font-size="8" font-family="monospace">-> fall through</text>
+
+ <!-- layers -->
+ <rect x="80" y="60" width="560" height="34" rx="5" fill="rgba(57,255,20,0.08)" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="96" y="81" fill="#39FF14" font-size="10" font-family="monospace">system property: -De3d.ipd=6.3 ← WINS</text>
+
+ <rect x="80" y="112" width="460" height="34" rx="5" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="96" y="133" fill="#40b0d0" font-size="10" font-family="monospace">config file: ~/.config/aukio/config.yaml (e3d: section)</text>
+
+ <rect x="80" y="164" width="360" height="34" rx="5" fill="rgba(32,112,192,0.08)" stroke="#2070c0" stroke-width="1.5"/>
+ <text x="96" y="185" fill="#2070c0" font-size="10" font-family="monospace">built-in defaults (in code)</text>
+
+ <text x="80" y="222" fill="#999" font-size="9" font-family="monospace">a missing key - or a missing file - falls through to the layer below</text>
+ <text x="80" y="240" fill="#999" font-size="9" font-family="monospace">file location override: -Daukio.config=/path/to.yaml (legacy fallback: -De3d.config=)</text>
+</svg>
--- /dev/null
+:PROPERTIES:
+:CUSTOM_ID: configuration
+:END:
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Configuration - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \setlength{\parindent}{15pt}
+#+LATEX_HEADER: \usepackage{palatino}
+#+LATEX_HEADER: \usepackage{charter}
+#+HTML_HEAD: <style type="text/css">body { max-width: 100%;}</style>
+
+[[file:../index.html#outline-container-configuration][<- Back to index]]
+
+* One YAML file for the whole family
+:PROPERTIES:
+:CUSTOM_ID: one-file
+:END:
+
+All Aukio programs — the engine, the workspace application, and
+environment plugins — share a single configuration file:
+
+#+BEGIN_SRC text
+~/.config/aukio/config.yaml
+#+END_SRC
+
+A missing file is not an error: every setting falls back to its
+built-in default. Override the location with
+=-Daukio.config=/path/to.yaml= (the legacy =-De3d.config== is honored
+as a fallback for engine-only tooling).
+
+The file is divided into *sections*, one per tenant, with flat
+camelCase keys inside each:
+
+#+BEGIN_SRC yaml
+e3d: # the engine (this page)
+ ipdCm: 6.3
+ telemetryIntervalSeconds: 10
+fo4: # the Fallout 4 environment plugin
+ path: /home/you/games/Fallout 4
+app: # the workspace application
+ state: ... # written by the app at runtime
+#+END_SRC
+
+Keys a consumer does not recognize are preserved verbatim through
+writes, so sections can evolve independently.
+
+* Engine keys
+:PROPERTIES:
+:CUSTOM_ID: engine-keys
+:END:
+
+The engine reads its settings through =EngineConfig=, a thin facade
+over the shared file. Every key can *also* be set as a system
+property, and the property always wins:
+
+| Config key (=e3d= section) | System property | Default | Controls |
+|-------------------------------+----------------------------+----------------------------------+----------|
+| =ipdCm= | =-De3d.ipd= | 6.5 | interpupillary distance for [[file:Stereoscopic rendering/][stereo rendering]] |
+| =bugReportDir= | =-De3d.bugreport.dir= | =~/.local/share/aukio/bugreports= | where bug reports are written |
+| =logDir= | =-De3d.log.dir= | =~/.cache/aukio/logs= | persistent rolling log location |
+| =telemetryIntervalSeconds= | =-De3d.telemetry.interval= | 5 | seconds between telemetry log lines |
+
+#+CAPTION: Resolution order for every engine key: the system property wins; otherwise the config file; otherwise the built-in default.
+[[file:Config precedence.svg]]
+
+So a permanent preference goes in the file, a one-off experiment on
+the command line:
+
+#+BEGIN_SRC sh
+java -De3d.ipd=6.3 -jar my-app.jar # this run only
+#+END_SRC
+
+* Live reload and safe writes
+:PROPERTIES:
+:CUSTOM_ID: live-reload
+:END:
+
+*Reads* check the file's (mtime, size, file-key) stamp and re-parse
+only when it changed — a hand edit made while an application is
+running becomes visible on the next read, with no restart and no
+polling cost when nothing changed.
+
+*Writes* (an application storing a setting at runtime) go through
+=AukioConfig.setString= and friends, and are engineered so that
+concurrent human edits survive:
+
+#+CAPTION: The write path. The sidecar lock serializes cooperating processes; the fresh re-read under the lock means a hand edit made between the writer's read and write is merged, not clobbered.
+[[file:Atomic write.svg]]
+
+Two honest limitations:
+
+- SnakeYAML rewrites the whole file on the first =set=, so
+ human-written header comments are lost once an application writes.
+ Keep conventions in documentation, not in the file's comments.
+- An editor saving a *stale buffer* over newer YAML is the one race
+ no file format can solve — your editor's changed-on-disk warning
+ (Emacs has one) is the only guard.
+
+Instances are cached per resolved path in a per-JVM registry:
+=AukioConfig.get()= returns the same instance to every caller in the
+process (engine, app, plugins), so the stamp cache is shared and a
+re-parse happens at most once per change per JVM.
+
+* Writing a consumer
+:PROPERTIES:
+:CUSTOM_ID: writing-a-consumer
+:END:
+
+#+BEGIN_SRC java
+import eu.svjatoslav.aukio.cfg.AukioConfig;
+
+// read (dotted path, caller-supplied fallback):
+double ipd = AukioConfig.get().getDouble("e3d.ipdCm", 6.5);
+String dir = AukioConfig.get().getString("e3d.logDir",
+ "~/.cache/aukio/logs");
+
+// write (locks, re-reads, merges, atomic rename):
+AukioConfig.get().setString("e3d.ipdCm", "6.3");
+#+END_SRC
+
+The class is covered by =AukioConfigTest= (9 tests) in the engine
+repository, including the concurrent-write and unknown-key
+preservation behavior.
+
+* Related classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class | Role |
+|----------------+-------------------------------------------------------------|
+| =AukioConfig= | shared YAML store: stamp-cached reads, locked atomic writes |
+| =EngineConfig= | engine facade: the four =e3d= keys + property overrides |
+
+[[file:../index.html#outline-container-configuration][Back to main documentation]]
--- /dev/null
+<svg viewBox="0 0 720 310" width="720" height="310" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ <marker id="ed-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#40b0d0"/>
+ </marker>
+ </defs>
+
+ <rect width="720" height="310" fill="#061018"/>
+
+ <text x="360" y="30" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">From AWT event to component callback</text>
+
+ <g font-family="monospace">
+ <!-- row 1 -->
+ <rect x="35" y="60" width="190" height="56" rx="5" fill="rgba(57,255,20,0.07)" stroke="#39FF14" stroke-width="1.5"/>
+ <text x="130" y="84" fill="#39FF14" font-size="10" text-anchor="middle">AWT event</text>
+ <text x="130" y="102" fill="#999" font-size="8" text-anchor="middle">press, release, move, wheel</text>
+
+ <rect x="265" y="60" width="190" height="56" rx="5" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="360" y="84" fill="#40b0d0" font-size="10" text-anchor="middle">InputManager</text>
+ <text x="360" y="102" fill="#999" font-size="8" text-anchor="middle">collects; splits shift+wheel to horizontal</text>
+
+ <rect x="495" y="60" width="190" height="56" rx="5" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5"/>
+ <text x="590" y="84" fill="#c05088" font-size="10" text-anchor="middle">paint pass</text>
+ <text x="590" y="102" fill="#999" font-size="8" text-anchor="middle">per-tile hit test, before clipping</text>
+
+ <line x1="227" y1="88" x2="261" y2="88" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#ed-arr)"/>
+ <line x1="457" y1="88" x2="491" y2="88" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#ed-arr)"/>
+ <line x1="590" y1="118" x2="590" y2="184" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#ed-arr)"/>
+
+ <!-- row 2 (snake back) -->
+ <rect x="495" y="186" width="190" height="56" rx="5" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5"/>
+ <text x="590" y="210" fill="#c05088" font-size="10" text-anchor="middle">flush</text>
+ <text x="590" y="228" fill="#999" font-size="8" text-anchor="middle">combine: first non-null hit wins</text>
+
+ <rect x="265" y="186" width="190" height="56" rx="5" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="360" y="210" fill="#40b0d0" font-size="10" text-anchor="middle">RenderingContext</text>
+ <text x="360" y="228" fill="#999" font-size="8" text-anchor="middle">handlePossibleComponentMouseEvent</text>
+
+ <rect x="35" y="186" width="190" height="56" rx="5" fill="rgba(221,153,0,0.08)" stroke="#dd9900" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="130" y="210" fill="#dd9900" font-size="10" text-anchor="middle">component callback</text>
+ <text x="130" y="228" fill="#999" font-size="8" text-anchor="middle">mouseClicked / hover / wheel</text>
+
+ <line x1="493" y1="214" x2="459" y2="214" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#ed-arr)"/>
+ <line x1="263" y1="214" x2="229" y2="214" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#ed-arr)"/>
+ </g>
+
+ <text x="60" y="282" fill="#999" font-size="9" font-family="monospace">stereo: only the eye whose viewport contains the cursor combines hits</text>
+ <text x="60" y="300" fill="#999" font-size="9" font-family="monospace">enter/exit transitions fire mouseEntered / mouseExited</text>
+</svg>
--- /dev/null
+<svg viewBox="0 0 720 250" width="720" height="250" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ <marker id="fm-arrG" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#39FF14"/>
+ </marker>
+ <marker id="fm-arrP" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#c05088"/>
+ </marker>
+ </defs>
+
+ <rect width="720" height="250" fill="#061018"/>
+
+ <text x="360" y="28" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">Focus is single-level</text>
+
+ <g font-family="monospace">
+ <rect x="60" y="80" width="260" height="80" rx="6" fill="rgba(57,255,20,0.07)" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="190" y="112" fill="#39FF14" font-size="11" text-anchor="middle">WorldNavigationUserInputTracker</text>
+ <text x="190" y="132" fill="#999" font-size="9" text-anchor="middle">default: WASD + arrows fly the camera</text>
+ <text x="190" y="148" fill="#999" font-size="9" text-anchor="middle">(KeyboardFocusStack's home state)</text>
+
+ <rect x="400" y="80" width="260" height="80" rx="6" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="530" y="112" fill="#c05088" font-size="11" text-anchor="middle">GuiComponent (focused)</text>
+ <text x="530" y="132" fill="#999" font-size="9" text-anchor="middle">keys go to the widget</text>
+ <text x="530" y="148" fill="#999" font-size="9" text-anchor="middle">red wireframe border marks focus</text>
+
+ <line x1="322" y1="102" x2="394" y2="102" stroke="#39FF14" stroke-width="1.5" marker-end="url(#fm-arrG)"/>
+ <text x="358" y="94" fill="#39FF14" font-size="9" text-anchor="middle">push: click</text>
+
+ <line x1="398" y1="140" x2="326" y2="140" stroke="#c05088" stroke-width="1.5" marker-end="url(#fm-arrP)"/>
+ <text x="362" y="132" fill="#c05088" font-size="9" text-anchor="middle">pop: ESC / middle click</text>
+ </g>
+
+ <text x="60" y="204" fill="#999" font-size="9" font-family="monospace">pop always returns to camera navigation - never to a previously focused widget</text>
+ <text x="60" y="222" fill="#999" font-size="9" font-family="monospace">the class is called KeyboardFocusStack for historical reasons; there is no stack</text>
+ <text x="60" y="240" fill="#999" font-size="9" font-family="monospace">push notifies the old owner with focusLost() and forgets it</text>
+</svg>
--- /dev/null
+<svg viewBox="0 0 720 300" width="720" height="300" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ <marker id="uv-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#40b0d0"/>
+ </marker>
+ </defs>
+
+ <rect width="720" height="300" fill="#061018"/>
+
+ <text x="360" y="28" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">A click lands in texture pixels</text>
+
+ <!-- tilted quad -->
+ <polygon points="80,90 300,60 320,220 60,240" fill="rgba(64,176,208,0.06)" stroke="#40b0d0" stroke-width="1.5"/>
+ <line x1="153" y1="80" x2="147" y2="232" stroke="#40b0d0" stroke-width="0.5" stroke-opacity="0.6"/>
+ <line x1="227" y1="70" x2="233" y2="226" stroke="#40b0d0" stroke-width="0.5" stroke-opacity="0.6"/>
+ <line x1="73" y1="140" x2="307" y2="115" stroke="#40b0d0" stroke-width="0.5" stroke-opacity="0.6"/>
+ <line x1="67" y1="190" x2="313" y2="168" stroke="#40b0d0" stroke-width="0.5" stroke-opacity="0.6"/>
+ <text x="80" y="54" fill="#40b0d0" font-size="9" font-family="monospace">textured panel in 3D</text>
+
+ <!-- click crosshair -->
+ <line x1="180" y1="150" x2="200" y2="150" stroke="#FF4444" stroke-width="1.5" filter="url(#glow)"/>
+ <line x1="190" y1="140" x2="190" y2="160" stroke="#FF4444" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="170" y="132" fill="#FF4444" font-size="9" font-family="monospace">click (x,y)</text>
+
+ <!-- mapping arrow -->
+ <line x1="330" y1="150" x2="424" y2="150" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#uv-arr)"/>
+ <text x="377" y="140" fill="#40b0d0" font-size="8" font-family="monospace" text-anchor="middle">perspective-correct</text>
+ <text x="377" y="164" fill="#40b0d0" font-size="8" font-family="monospace" text-anchor="middle">UV interpolation</text>
+
+ <!-- texture bitmap -->
+ <rect x="430" y="70" width="160" height="160" fill="rgba(192,80,136,0.06)" stroke="#c05088" stroke-width="1.5" filter="url(#glow)"/>
+ <g stroke="#c05088" stroke-width="0.5" stroke-opacity="0.5">
+ <line x1="470" y1="70" x2="470" y2="230"/><line x1="510" y1="70" x2="510" y2="230"/><line x1="550" y1="70" x2="550" y2="230"/>
+ <line x1="430" y1="110" x2="590" y2="110"/><line x1="430" y1="150" x2="590" y2="150"/><line x1="430" y1="190" x2="590" y2="190"/>
+ </g>
+ <circle cx="505" cy="135" r="4" fill="#FF4444" filter="url(#glow)"/>
+ <text x="430" y="60" fill="#c05088" font-size="9" font-family="monospace">texture bitmap</text>
+ <text x="516" y="132" fill="#FF4444" font-size="9" font-family="monospace">(u,v)</text>
+
+ <text x="60" y="272" fill="#999" font-size="9" font-family="monospace">u and v arrive in primary-texture pixels, NaN when the hit shape is untextured</text>
+ <text x="60" y="290" fill="#999" font-size="9" font-family="monospace">the same math drives hover tracking and click forwarding into captured app windows</text>
+</svg>
--- /dev/null
+:PROPERTIES:
+:CUSTOM_ID: gui-components
+:END:
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: GUI Components - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \setlength{\parindent}{15pt}
+#+LATEX_HEADER: \usepackage{palatino}
+#+LATEX_HEADER: \usepackage{charter}
+#+HTML_HEAD: <style type="text/css">body { max-width: 100%;}</style>
+
+[[file:../index.html#outline-container-gui-components][<- Back to index]]
+
+* Interactive widgets in 3D space
+:PROPERTIES:
+:CUSTOM_ID: interactive-widgets
+:END:
+
+=GuiComponent= is the base class for interactive widgets that live
+*in the world*, not in a 2D overlay: panels you can click, hover and
+type into. It combines a composite shape (so it can hold any visual
+content — text canvases, textures, other shapes) with two callback
+interfaces: =MouseInteractionController= for the mouse and
+=KeyboardInputHandler= for the keyboard.
+
+Click a component and it takes keyboard focus, marked by a red
+wireframe border around it; press *ESC* or *middle-click* it and
+focus returns to camera navigation. While a component is focused,
+keyboard input goes to it instead of moving the camera.
+
+#+BEGIN_SRC java
+GuiComponent widget = new GuiComponent(
+ new Transform(new Point3D(0, 0, 300)), // where in the world
+ viewPanel,
+ new Point3D(400, 300, 0)); // width, height, depth
+widget.addShape(someContent); // any shapes
+viewPanel.getRootShapeCollection().addShape(widget);
+#+END_SRC
+
+* Focus is single-level
+:PROPERTIES:
+:CUSTOM_ID: focus-model
+:END:
+
+=KeyboardFocusStack= owns exactly one focus owner at a time — despite
+the name, there is no stack. =pushFocusOwner= replaces the current
+owner (notifying the old one with =focusLost= and forgetting it);
+=popFocusOwner= — triggered by ESC or a middle click — always falls
+back to the default handler, =WorldNavigationUserInputTracker=
+(WASD/arrow camera flight). It never restores a previously focused
+widget.
+
+#+CAPTION: The two focus states and the transitions between them.
+[[file:Focus model.svg]]
+
+The default handler is what makes drag-look and wheel-move keep
+working even while a panel has focus: those live in the input
+manager, not in the focus owner. Only discrete key events and wheel
+routing change hands.
+
+* How events reach a component
+:PROPERTIES:
+:CUSTOM_ID: event-dispatch
+:END:
+
+Mouse hit detection piggybacks on rendering. During painting, every
+tile notes which interactive shape (if any) lies under the cursor —
+before its clipping is applied. When the pass is flushed, the first
+non-null hit across tiles becomes the view's
+=currentObjectUnderMouseCursor=, and
+=RenderingContext.handlePossibleComponentMouseEvent= dispatches the
+callbacks: =mouseEntered= / =mouseExited= on transitions,
+=mouseClicked= on presses, =mouseHover= on moves.
+
+#+CAPTION: The dispatch path. The hit is found as a side effect of painting, combined at flush time, and delivered to the component.
+[[file:Event dispatch.svg]]
+
+In [[file:Stereoscopic%20rendering/][stereo mode]] the same object
+sits at a different screen X per eye, so hits are combined only for
+the pass whose viewport actually contains the cursor.
+
+Clicks carry more than a button number: for textured shapes the
+engine computes the *perspective-correct texture coordinates* of the
+exact clicked point, in primary-texture pixels (=NaN= for untextured
+shapes). That is what lets a component forward input into a captured
+application window pixel-accurately — the aukio workspace's Firefox
+and terminal panels are built on it.
+
+#+CAPTION: Screen point to texture pixel. Hover uses the same path.
+[[file:Texture UV picking.svg]]
+
+* The callback surface
+:PROPERTIES:
+:CUSTOM_ID: callback-surface
+:END:
+
+=MouseInteractionController= (default methods shown delegate to the
+simpler overloads, so override only what you need):
+
+| Callback | When it fires | Notes |
+|----------+---------------+-------|
+| =mouseClicked(button)= | any click on the component | button: 1 left, 2 middle, 3 right |
+| =mouseClicked(button, u, v)= | click on a textured component | =u=, =v= in texture pixels, =NaN= untextured |
+| =mouseClicked(button, u, v, focusStack)= | as above, with the view's focus stack | override this one to take focus (see below) |
+| =mouseHover(u, v)= | pointer moves over the component | throttled by the caller |
+| =mouseWheelMoved(vUnits, hUnits)= | wheel while the component has focus | return =false= to fall through to camera movement |
+| =mouseEntered= / =mouseExited= | cursor crosses the component boundary | |
+
+Every callback returns =true= when the view needs a repaint as a
+consequence — the engine skips rendering otherwise.
+
+Two dispatch rules to know:
+
+- Components that *take focus on click* should override the 4-arg
+ =mouseClicked= and use the supplied =KeyboardFocusStack= — never a
+ stored =ViewPanel= field. Headless scenes (the golden-image
+ harness) construct components with a null panel, and the field
+ dereference would NPE inside the render thread.
+- The wheel routes to the *focused* component, and =GuiComponent=
+ consumes it by default, so the camera stays put while you scroll
+ inside a widget. Horizontal wheel input arrives from AWT as
+ shift-modified vertical rotation and is split out by the input
+ manager.
+
+* A complete example: the text editor
+:PROPERTIES:
+:CUSTOM_ID: text-editor
+:END:
+
+=textEditorComponent/TextEditComponent= is a full multi-line editor
+built on =GuiComponent= — proof the interaction layer carries real
+widgets. It renders through a [[file:SDF%20textures/][TextCanvas]]
+backed by a =Page= of =TextLine=s, skinnable via =LookAndFeel=, and
+supports:
+
+- cursor navigation (arrows, Home/End, Page Up/Down) and
+ shift-selection
+- clipboard: Ctrl+C / Ctrl+X / Ctrl+V, select-all with Ctrl+A
+- word-level movement with Ctrl+Left / Ctrl+Right
+- Tab indentation and Shift+Tab dedentation, single line or block
+- automatic scrolling when the cursor leaves the visible area
+
+(Tab reaches the widget at all because =ViewPanel= disables AWT
+focus-traversal keys — otherwise the focus subsystem would eat it.)
+
+The =TextEditorDemo= and =TextEditorDemo2= demos in aukio-3d-demos
+show the editor in a scene; both build deterministically for the
+golden-image harness.
+
+* Related classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class | Role |
+|------------------------------+--------------------------------------------------|
+| =GuiComponent= | base widget: shape + focus + mouse/keyboard glue |
+| =KeyboardFocusStack= | single-level focus owner management |
+| =MouseInteractionController= | the mouse callback interface |
+| =KeyboardInputHandler= | the keyboard callback interface |
+| =InputManager= | AWT collection, wheel routing, extra buttons |
+| =TextEditComponent= | full text editor built on =GuiComponent= |
+
+[[file:../index.html#outline-container-gui-components][Back to main documentation]]
pass produced visibly artificial results and was removed; finer texels
cost more CPU on the GI threads but look right.)
+* Phased scheduling: light floods near-to-far
+:PROPERTIES:
+:CUSTOM_ID: phased-scheduling
+:END:
+
+The scene is lit in three strict global phases, each walking polygons
+in order of distance from the camera, nearest first
+(=-De3d.gi.phases=true=, default):
+
+1. *Phase A — centroid direct*: each polygon gets ONE direct-light
+ evaluation at its centroid (shadow rays to all lamps at once), and
+ the result is stamped onto the whole polygon in a single step. The
+ world goes from the uniform medium start to flat per-polygon
+ lighting as a visible wave sweeping away from the camera — cheap
+ (one sample point per polygon) and fast even on huge scenes.
+2. *Phase B — centroid multi-bounce*: bounce rays from centroids only,
+ still one sample point per polygon, feeding the indirect field.
+ Converges scene-wide before any texel work starts, so phase C never
+ begins in an indirect dark age.
+3. *Phase C — per-texel refinement*: lightmapped triangles are refined
+ near-to-far. A triangle entering this phase has its texels seeded
+ from its converged centroid values, then sampled per texel (the
+ two-ray visit described above) until its on-screen estimate stops
+ moving; it then *graduates* out of the active window — ray sampling
+ stops — into a *freeze tail* that keeps recompositing the now-fixed
+ target until the estimate has fully glided in
+ (=-De3d.gi.freezeThreshold=, default 0.1 light units), and only then
+ freezes. Without the tail, graduating at the calm threshold would
+ leave a few light units of the centroid stamp frozen into the
+ texture, visible as brightness seams between adjacent triangles.
+ When every lightmap has frozen, the workers idle.
+
+Phases B and C sample only a bounded *active window* of the nearest
+entries (=-De3d.gi.activeWindow=, default 256), so near geometry
+converges before CPU is spent on far geometry. If the camera moves
+more than =e3d.gi.resortDistance= (default 25 world units), the
+not-yet-activated work is re-sorted to the new position — finished
+polygons keep their lighting. Plain (non-lightmapped) polygons finish
+at phase B; their per-polygon result *is* their final resolution.
+=-De3d.gi.phases=false= restores the legacy flat round-robin over all
+texels at once.
+
* One sample: a shadow ray and a bounce ray
:PROPERTIES:
:CUSTOM_ID: one-sample
:END:
-The work list is flat: one item per lightmap texel (and one per plain
-polygon, see below). Worker threads walk it round-robin, and every
-visit to a texel casts exactly two rays from the texel's world
-position, nudged slightly off the surface along the normal:
+In phase C (or everywhere in legacy mode), every visit to a texel
+casts exactly two rays from the texel's world position, nudged
+slightly off the surface along the normal:
1. *Shadow ray* toward a lamp. Answers visible/occluded, cached in the
texel's visibility bits. On a texel's *first* visit all lamps are
light fades out gradually instead of popping — the same pair of EMAs
that accumulates light also drains it.
- *Convergence detection*: per-sample deltas are pure noise (and with a
- constant alpha they never settle), so the system watches the average
- per-texel movement of the on-screen estimate; after five composite
- updates below =e3d.gi.calmThreshold= (default 1.0 light unit) the
- workers drop to a low duty cycle (~50% of sweep time, capped) instead
- of burning CPU. Any scene or light change rebuilds the snapshot and
- restarts full-speed tracing.
+ constant alpha they never settle), so the system watches the movement
+ of the on-screen estimate instead. Phased mode graduates an entry out
+ of the active window after three consecutive calm judgements
+ (=-De3d.gi.calmThreshold=, default 1.0 light unit) and idles when the
+ last one graduates; legacy mode watches the average per-texel
+ movement and idles after five calm composite updates. Any scene or
+ light change rebuilds the snapshot and restarts from phase A.
- *Despeckle*: at composite time the indirect channel is blended 50/50
with the mean of its valid 4-neighbors, killing single-texel Monte
Carlo spikes without blurring real gradients.
Both are called from parallel render-pool threads, so they only read
volatile caches — never trace.
+* Two-sided surfaces
+:PROPERTIES:
+:CUSTOM_ID: two-sided
+:END:
+
+Polygons rendered with backface culling off are visible from both
+sides, and each side must show its own lighting — otherwise the camera
+sees the front side's light through the surface (undersides glowing
+with the top's light):
+
+- =SolidPolygon= computes a second shaded color with the negated
+ normal every frame; the painter picks front or reverse color by the
+ signed screen area, the same test backface culling uses.
+- =LightmappedCompositeShape= wraps a two-sided source into a
+ lightmapped front triangle /plus/ a plain solid triangle of reversed
+ winding for the back, and turns culling on for both halves: the
+ lightmap only knows its front side, so the back side renders with
+ ordinary one-sided direct lighting. Each side's pixels are owned by
+ exactly one surface, so nothing z-fights and no holes appear even
+ when the source model's winding is inconsistent.
+
+* Coordinate spaces: lights are world, geometry is local
+:PROPERTIES:
+:CUSTOM_ID: coordinate-spaces
+:END:
+
+Direct lighting (=SolidPolygon=) and GI both read polygon centers,
+normals and lightmap coordinates in the shape's LOCAL space, while
+=LightSource= positions, markers and GI rays live in WORLD space. For
+shapes with identity transforms the two coincide — but a composite
+with a non-identity transform (scaled/rotated OBJ model) silently
+shifts every effective light position into the wrong place (mirrored,
+scaled, offset — the classic symptom is light apparently coming from a
+spot where no source exists, e.g. from below the ground).
+
+Until the engine reconciles the two spaces, demos must BAKE static
+model transforms into the vertex coordinates at load time (see
+=ObjLoaderDemo.buildScene=) and leave the model transform identity.
+Animated transforms above a lightmapped composite were already
+unsupported; this is the same rule, one level up.
+
* Enabling GI
:PROPERTIES:
:CUSTOM_ID: enabling
| =e3d.gi.initialIrradiance=| 128 | uniform medium start (0..255 light units) |
| =e3d.gi.calmThreshold= | 1.0 | convergence: avg estimate movement (light units) |
| =e3d.gi.despeckle= | true | neighbor-smoothing of indirect at composite time |
+| =e3d.gi.phases= | true | phased near-to-far scheduling; =false= = legacy flat round-robin |
+| =e3d.gi.activeWindow= | 256 | phased mode: concurrently sampled entries, nearest first |
+| =e3d.gi.resortDistance= | 25 | camera move (world units) that re-sorts pending work |
+| =e3d.gi.freezeThreshold= | 0.1 | phase C display-freeze: tail composites stop below this estimate movement |
| =e3d.gi.debug= | false | sweep statistics to stdout |
| =e3d.gi.dumpLightmaps= | (unset) | dump composite lightmaps as PNGs to the given dir |
--- /dev/null
+<svg viewBox="0 0 720 300" width="720" height="300" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ </defs>
+
+ <rect width="720" height="300" fill="#061018"/>
+
+ <text x="360" y="28" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">One cell = eight parallel int arrays</text>
+ <text x="360" y="46" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">internal cell: all eight arrays are child pointers; solid leaf: state, color, illumination</text>
+
+ <!-- column headers -->
+ <g font-family="monospace" font-size="8" fill="#999">
+ <text x="169" y="64" text-anchor="middle" fill="#39FF14" filter="url(#glow)">0</text>
+ <text x="233" y="64" text-anchor="middle">1</text>
+ <text x="297" y="64" text-anchor="middle">2</text>
+ <text x="361" y="64" text-anchor="middle">3</text>
+ <text x="425" y="64" text-anchor="middle">4</text>
+ <text x="489" y="64" text-anchor="middle">5</text>
+ </g>
+
+ <!-- master column highlight -->
+ <rect x="140" y="70" width="58" height="202" fill="rgba(57,255,20,0.06)" stroke="#39FF14" stroke-width="1" filter="url(#glow)"/>
+
+ <!-- array rows -->
+ <g font-family="monospace">
+ <!-- row template: label + 6 cells -->
+ <g stroke="#1a3a4a" stroke-width="1" fill="rgba(64,176,208,0.05)">
+ <rect x="140" y="72" width="58" height="18"/><rect x="204" y="72" width="58" height="18"/><rect x="268" y="72" width="58" height="18"/><rect x="332" y="72" width="58" height="18"/><rect x="396" y="72" width="58" height="18"/><rect x="460" y="72" width="58" height="18"/>
+ <rect x="140" y="98" width="58" height="18"/><rect x="204" y="98" width="58" height="18"/><rect x="268" y="98" width="58" height="18"/><rect x="332" y="98" width="58" height="18"/><rect x="396" y="98" width="58" height="18"/><rect x="460" y="98" width="58" height="18"/>
+ <rect x="140" y="124" width="58" height="18"/><rect x="204" y="124" width="58" height="18"/><rect x="268" y="124" width="58" height="18"/><rect x="332" y="124" width="58" height="18"/><rect x="396" y="124" width="58" height="18"/><rect x="460" y="124" width="58" height="18"/>
+ <rect x="140" y="150" width="58" height="18"/><rect x="204" y="150" width="58" height="18"/><rect x="268" y="150" width="58" height="18"/><rect x="332" y="150" width="58" height="18"/><rect x="396" y="150" width="58" height="18"/><rect x="460" y="150" width="58" height="18"/>
+ <rect x="140" y="176" width="58" height="18"/><rect x="204" y="176" width="58" height="18"/><rect x="268" y="176" width="58" height="18"/><rect x="332" y="176" width="58" height="18"/><rect x="396" y="176" width="58" height="18"/><rect x="460" y="176" width="58" height="18"/>
+ <rect x="140" y="202" width="58" height="18"/><rect x="204" y="202" width="58" height="18"/><rect x="268" y="202" width="58" height="18"/><rect x="332" y="202" width="58" height="18"/><rect x="396" y="202" width="58" height="18"/><rect x="460" y="202" width="58" height="18"/>
+ <rect x="140" y="228" width="58" height="18"/><rect x="204" y="228" width="58" height="18"/><rect x="268" y="228" width="58" height="18"/><rect x="332" y="228" width="58" height="18"/><rect x="396" y="228" width="58" height="18"/><rect x="460" y="228" width="58" height="18"/>
+ <rect x="140" y="254" width="58" height="18"/><rect x="204" y="254" width="58" height="18"/><rect x="268" y="254" width="58" height="18"/><rect x="332" y="254" width="58" height="18"/><rect x="396" y="254" width="58" height="18"/><rect x="460" y="254" width="58" height="18"/>
+ </g>
+ <g font-size="10" fill="#c05088">
+ <text x="60" y="85">cell1</text>
+ <text x="60" y="111">cell2</text>
+ <text x="60" y="137">cell3</text>
+ <text x="60" y="163">cell4</text>
+ <text x="60" y="189">cell5</text>
+ <text x="60" y="215">cell6</text>
+ <text x="60" y="241">cell7</text>
+ <text x="60" y="267">cell8</text>
+ </g>
+ <g font-size="9" fill="#999">
+ <text x="540" y="85">state / child 1</text>
+ <text x="540" y="111">color / child 2</text>
+ <text x="540" y="137">illum. / child 3</text>
+ <text x="540" y="163">child 4</text>
+ <text x="540" y="189">child 5</text>
+ <text x="540" y="215">child 6</text>
+ <text x="540" y="241">child 7</text>
+ <text x="540" y="267">child 8</text>
+ </g>
+ </g>
+
+ <text x="140" y="288" fill="#39FF14" font-size="9" font-family="monospace">← index 0 = master cell</text>
+ <text x="360" y="288" fill="#999" font-size="9" font-family="monospace">allocation = scan for a free slot; exhaustion throws</text>
+</svg>
--- /dev/null
+<svg viewBox="0 0 720 340" width="720" height="340" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ </defs>
+
+ <rect width="720" height="340" fill="#061018"/>
+
+ <text x="360" y="28" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">Recursive 8-way subdivision</text>
+ <text x="360" y="46" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">a cube splits into 8 octants; only occupied octants split further</text>
+
+ <!-- 2D analogue: nested squares -->
+ <rect x="40" y="70" width="220" height="220" fill="rgba(57,255,20,0.05)" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+ <line x1="150" y1="70" x2="150" y2="290" stroke="#39FF14" stroke-width="1" stroke-opacity="0.7"/>
+ <line x1="40" y1="180" x2="260" y2="180" stroke="#39FF14" stroke-width="1" stroke-opacity="0.7"/>
+ <text x="46" y="84" fill="#39FF14" font-size="9" font-family="monospace">L0 master</text>
+
+ <!-- level 1: bottom-right quadrant splits -->
+ <line x1="205" y1="180" x2="205" y2="290" stroke="#40b0d0" stroke-width="1" stroke-opacity="0.8"/>
+ <line x1="150" y1="235" x2="260" y2="235" stroke="#40b0d0" stroke-width="1" stroke-opacity="0.8"/>
+ <text x="154" y="194" fill="#40b0d0" font-size="9" font-family="monospace">L1</text>
+
+ <!-- level 2: top-right sub-quadrant splits -->
+ <line x1="232" y1="180" x2="232" y2="235" stroke="#c05088" stroke-width="1" stroke-opacity="0.9"/>
+ <line x1="205" y1="207" x2="260" y2="207" stroke="#c05088" stroke-width="1" stroke-opacity="0.9"/>
+ <text x="209" y="226" fill="#c05088" font-size="9" font-family="monospace">L2</text>
+
+ <text x="40" y="312" fill="#999" font-size="9" font-family="monospace">2D analogue (4 quadrants) of the 3D 8-octant split</text>
+
+ <!-- tree view -->
+ <g font-family="monospace">
+ <rect x="400" y="70" width="120" height="30" rx="4" fill="rgba(57,255,20,0.08)" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="460" y="89" fill="#39FF14" font-size="10" text-anchor="middle">master cell [0]</text>
+
+ <line x1="430" y1="100" x2="400" y2="146" stroke="#40b0d0" stroke-width="1"/>
+ <line x1="460" y1="100" x2="490" y2="146" stroke="#40b0d0" stroke-width="1"/>
+ <line x1="500" y1="100" x2="590" y2="146" stroke="#40b0d0" stroke-width="1"/>
+
+ <rect x="360" y="146" width="80" height="26" rx="4" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="400" y="163" fill="#40b0d0" font-size="10" text-anchor="middle">octant 1</text>
+ <rect x="450" y="146" width="80" height="26" rx="4" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="490" y="163" fill="#40b0d0" font-size="10" text-anchor="middle">octant 2</text>
+ <rect x="540" y="146" width="80" height="26" rx="4" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="580" y="163" fill="#40b0d0" font-size="10" text-anchor="middle">...</text>
+ <text x="360" y="140" fill="#999" font-size="9">level 1: 8 cells</text>
+
+ <line x1="470" y1="172" x2="450" y2="216" stroke="#c05088" stroke-width="1"/>
+ <line x1="510" y1="172" x2="540" y2="216" stroke="#c05088" stroke-width="1"/>
+
+ <rect x="410" y="216" width="80" height="26" rx="4" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5"/>
+ <text x="450" y="233" fill="#c05088" font-size="10" text-anchor="middle">sub 1</text>
+ <rect x="500" y="216" width="80" height="26" rx="4" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5"/>
+ <text x="540" y="233" fill="#c05088" font-size="10" text-anchor="middle">...</text>
+ <text x="410" y="210" fill="#999" font-size="9">one octant splits again: level 2</text>
+ </g>
+
+ <text x="360" y="330" fill="#999" font-size="9" font-family="monospace">only occupied regions subdivide - empty space is never allocated</text>
+</svg>
--- /dev/null
+<svg viewBox="0 0 720 330" width="720" height="330" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ </defs>
+
+ <rect width="720" height="330" fill="#061018"/>
+
+ <text x="360" y="28" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">Ray traversal: primary rays and shadow rays</text>
+
+ <!-- cell grid: 4 cols x 3 rows, 120x60 cells from x=140 y=70 -->
+ <g stroke="#1a3a4a" stroke-width="1" fill="none">
+ <rect x="140" y="70" width="480" height="180"/>
+ <line x1="260" y1="70" x2="260" y2="250"/>
+ <line x1="380" y1="70" x2="380" y2="250"/>
+ <line x1="500" y1="70" x2="500" y2="250"/>
+ <line x1="140" y1="130" x2="620" y2="130"/>
+ <line x1="140" y1="190" x2="620" y2="190"/>
+ </g>
+ <!-- empty cells (skipped) -->
+ <g stroke="#2070c0" stroke-width="1" stroke-dasharray="4 3" fill="rgba(32,112,192,0.05)">
+ <rect x="142" y="72" width="116" height="56"/>
+ <rect x="262" y="132" width="116" height="56"/>
+ <rect x="382" y="192" width="116" height="56"/>
+ </g>
+ <g font-family="monospace" font-size="8" fill="#2070c0">
+ <text x="152" y="86">empty: skipped</text>
+ <text x="272" y="146">empty: skipped</text>
+ <text x="392" y="206">empty: skipped</text>
+ </g>
+
+ <!-- occupied hit cell -->
+ <rect x="502" y="72" width="116" height="56" fill="rgba(192,80,136,0.18)" stroke="#c05088" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="510" y="90" fill="#c05088" font-size="8" font-family="monospace">hit voxel</text>
+
+ <!-- camera -->
+ <rect x="30" y="130" width="70" height="40" rx="5" fill="rgba(221,153,0,0.08)" stroke="#dd9900" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="65" y="154" fill="#dd9900" font-size="10" font-family="monospace" text-anchor="middle">camera</text>
+
+ <!-- primary ray -->
+ <line x1="100" y1="150" x2="502" y2="100" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="180" y="122" fill="#40b0d0" font-size="9" font-family="monospace">primary ray (one per pixel)</text>
+
+ <!-- light 1: lit -->
+ <circle cx="660" cy="50" r="9" fill="#dd9900" filter="url(#glow)"/>
+ <text x="640" y="34" fill="#dd9900" font-size="9" font-family="monospace">light 1</text>
+ <line x1="560" y1="86" x2="652" y2="54" stroke="#dd9900" stroke-width="1" stroke-dasharray="5 3"/>
+ <text x="590" y="120" fill="#dd9900" font-size="8" font-family="monospace">lit</text>
+
+ <!-- light 2: blocked -->
+ <circle cx="660" cy="300" r="9" fill="#dd9900" filter="url(#glow)"/>
+ <text x="640" y="322" fill="#dd9900" font-size="9" font-family="monospace">light 2</text>
+ <line x1="560" y1="110" x2="655" y2="292" stroke="#FF6600" stroke-width="1" stroke-dasharray="5 3"/>
+ <!-- blocker voxel -->
+ <rect x="596" y="196" width="22" height="22" fill="rgba(192,80,136,0.4)" stroke="#c05088" stroke-width="1.5"/>
+ <text x="566" y="236" fill="#FF6600" font-size="8" font-family="monospace">blocked → shadow</text>
+
+ <text x="60" y="316" fill="#999" font-size="9" font-family="monospace">traversal descends the octree hierarchy - empty regions are skipped without touching individual voxels</text>
+</svg>
--- /dev/null
+:PROPERTIES:
+:CUSTOM_ID: octree-ray-tracing
+:END:
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Octree Ray Tracing - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \setlength{\parindent}{15pt}
+#+LATEX_HEADER: \usepackage{palatino}
+#+LATEX_HEADER: \usepackage{charter}
+#+HTML_HEAD: <style type="text/css">body { max-width: 100%;}</style>
+
+[[file:../index.html#outline-container-octree-ray-tracing][<- Back to index]]
+
+* What the octree adds
+:PROPERTIES:
+:CUSTOM_ID: what-octree-adds
+:END:
+
+The raster pipeline draws triangles. The =renderer/octree= package
+offers a second, independent way to build a scene: a *voxel volume*.
+Space is stored as an octree — a tree that recursively subdivides a
+cube into eight octants — which compresses sparse or repetitive
+volumes well: empty regions are never allocated, and a solid room
+wall can stay one large cell until something forces it to split.
+
+On top of that volume, =renderer/octree/raytracer= implements a real
+*ray tracer*: it casts one ray per pixel through the octree, finds
+the first occupied voxel, and computes lighting by casting shadow
+rays from the hit point toward every light source. The ray tracer is
+a rendering curiosity next to the raster pipeline — but it is a
+complete one, with per-pixel shadows and progressive on-screen
+refinement.
+
+#+CAPTION: Left: the 2D analogue of recursive subdivision — only occupied regions split. Right: the same as a tree. Empty space costs no cells.
+[[file:Octree subdivision.svg]]
+
+* The data structure: a flat cell pool
+:PROPERTIES:
+:CUSTOM_ID: cell-pool
+:END:
+
+=OctreeVolume= stores the whole tree in a flat pool: eight parallel
+=int= arrays (=cell1= ... =cell8=), and a cell is just an index into
+them. The packing is economical:
+
+- a *solid leaf* uses =cell1= for its state marker, =cell2= for its
+ color and =cell3= for its illumination value;
+- an *internal cell* uses all eight arrays as child pointers —
+ =breakSolidCell= subdivides a leaf by writing eight freshly
+ allocated children into =cell1= ... =cell8=, inheriting the parent's
+ color and illumination.
+
+#+CAPTION: The flat cell pool. Index 0 is the master cell covering the whole world. Allocation scans for a free slot with wraparound; when the pool is full, allocation throws =IllegalStateException= (grow the pool at =initWorld= or reduce voxel density).
+[[file:Cell pool.svg]]
+
+There are no Java objects per cell and no pointers to chase beyond
+array indexing — the same cache-friendly layout philosophy as the
+raster pipeline's mesh blocks. Cell zero is the master cell, whose
+edge length is =masterCellSize= world units.
+
+Building a volume is incremental:
+
+#+BEGIN_SRC java
+OctreeVolume volume = new OctreeVolume();
+volume.initWorld(poolSize, masterCellSize);
+
+// single voxel:
+volume.putCell(x, y, z, color);
+
+// filled box of voxels:
+volume.fillRectangle(new IntegerPoint(x1, y1, z1),
+ new IntegerPoint(x2, y2, z2), color);
+#+END_SRC
+
+* Ray tracing through the volume
+:PROPERTIES:
+:CUSTOM_ID: ray-tracing
+:END:
+
+=RayTracer= is constructed with a target =Texture=, the volume, the
+light list and a =RaytracingCamera= (which wraps the scene's raster
+camera plus a magnification factor, and owns the texture the result
+is painted into). It implements =Runnable= — the demo runs it on a
+plain background thread:
+
+#+BEGIN_SRC java
+RaytracingCamera rtCamera = new RaytracingCamera(viewPanel.getCamera(), magnification);
+RayTracer rayTracer = new RayTracer(rtCamera.getTexture(),
+ octreeVolume, lights, rtCamera, viewPanel);
+new Thread(rayTracer).start();
+#+END_SRC
+
+For each pixel, =RaytracingCamera= generates a =Ray=; traversal
+(=OctreeVolume.traceCell=) descends the hierarchy, testing only the
+children a ray actually crosses and skipping empty subtrees entirely
+— empty space is never touched voxel by voxel. The first occupied
+cell hit yields the surface point and its =cell2= color.
+
+Lighting is per-pixel: from the hit point a shadow ray is cast
+toward every =LightSource=. If any occupied cell blocks that ray,
+the point is in shadow for that light; otherwise the light
+contributes to the pixel.
+
+#+CAPTION: A primary ray crosses the volume (empty cells are skipped wholesale), hits an occupied voxel, and shadow rays decide per-light visibility. Light 2 is blocked, so its contribution is shadowed.
+[[file:Ray traversal.svg]]
+
+The render is *progressive*: the tracer repaints its texture roughly
+once per second (=PROGRESS_UPDATE_FREQUENCY_MILLIS=), so a refining
+image appears immediately and sharpens as more pixels complete — you
+watch the shadows settle rather than waiting for a final image.
+
+* Trying it out
+:PROPERTIES:
+:CUSTOM_ID: trying-it-out
+:END:
+
+The *Octree* demo in the aukio-3d-demos project
+(=examples/OctreeDemo.java=) builds a voxel scene (spiral, tiled
+floor, a fractal) into an =OctreeVolume=, places light sources, and
+starts a background ray-trace render of the current view. The same
+scene builds deterministically for the golden-image harness, so the
+ray-traced output is regression-tested like every other demo.
+
+* Related classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class | Role |
+|--------------------+-------------------------------------------------------------|
+| =OctreeVolume= | flat-pool octree: allocation, voxel writes, ray traversal |
+| =IntegerPoint= | integer 3D coordinate for voxel addressing |
+| =RayTracer= | per-pixel rays, shadow rays, progressive texture updates |
+| =RaytracingCamera= | wraps the raster camera; generates per-pixel rays |
+| =Ray= | a single ray: origin, direction, hit point |
+| =LightSource= | a light the shadow rays are cast toward |
+
+[[file:../index.html#outline-container-octree-ray-tracing][Back to main documentation]]
|-----------+-------+--------+---------|
| Shapes | 3D [[file:../index.org::#vertex][vertices]], [[file:../index.org::#mesh][meshes]] | Scene data | Objects waiting to be drawn |
| Transform | World coordinates | Screen coordinates | Convert 3D positions to where they appear on screen (see [[file:../index.org::#coordinate-system][coordinate system]]); cull shapes outside the view frustum. Parallel: heavy subtrees fork onto the worker pool |
-| Sort | Unordered shapes | Ordered by depth | Ensure correct visibility (far objects painted first). Parallel merge sort for large scenes |
+| Sort | Unordered shapes | Ordered by depth | Establish the back-to-front order (translucent blending, early-z). Parallel radix sort for large scenes |
| Bin | Sorted shapes | Per-tile shape lists | Each paint tile iterates only shapes that can touch it. Parallel over the worker pool |
| Paint | Per-tile shape lists | Pixels in buffer | Tiles split the screen into independent work units so clearing and rasterization run in parallel across CPU cores |
| Present | Pixel buffer | Screen image | Hand the completed frame to a dedicated thread that copies it to the display |
To render it, we must convert to "screen space" — where it appears on
your monitor. This involves:
+- *Scale*: Optionally resize shapes uniformly about their local origin
+ (=Transform.setScale=); folded into the composed transform stack at
+ push time, so per-vertex cost is unchanged
- *Translation*: Move coordinates relative to camera position
- *Rotation*: Rotate coordinates based on camera orientation
- *Projection*: Convert 3D (x, y, z) to 2D (x, y) screen pixels
return Integer.compare(o1.shapeId, o2.shapeId);
#+END_SRC
-Above 8192 queued shapes the sort runs as an instrumented parallel
-merge sort on the shared worker pool; below that it is single-threaded.
+Above 8192 queued shapes the sort runs as a parallel radix sort on the
+shared worker pool: Z is mapped to unsigned-ordered long keys and
+stable-sorted by LSD radix passes, with equal-key runs fixed to
+ascending shape id — reproducing the comparator order exactly,
+bit-identical between serial and parallel. Below that threshold it is a
+single-threaded comparator sort.
*Why sort back-to-front?*
-This implements the *painter's algorithm* — like painting a landscape:
-first paint the sky (farthest), then mountains, then trees, then the
-foreground. Each layer covers what's behind it.
+Two consumers of the order, for different reasons:
+
+- *Translucent surfaces* are blended, and blending is order-dependent:
+ the farther layer must already be in the buffer when the nearer one
+ mixes with it. This is the *painter's algorithm* — like painting a
+ landscape: first the sky (farthest), then mountains, then trees,
+ then the foreground. Alpha-class shapes therefore paint in queue
+ order, back-to-front, testing but never writing depth.
#+INCLUDE: "Painter's algorithm.svg" export html
-Without sorting, nearby objects might be painted first and then covered
-by distant ones, causing visual errors. This is especially important for
-*transparent objects* — you need to see through the near ones to what's
-behind.
+- *Opaque surfaces* do not need the order for correctness — the
+ per-pixel [[file:Depth buffer/][depth buffer]] decides visibility.
+ They still benefit from it as a performance optimization: the paint
+ loop iterates them *front-to-back* (the queue reversed), so the
+ nearest surface writes depth first and every covered pixel behind
+ it fails the depth test early, before its texture is even fetched.
+
+Without sorting, translucent objects would blend against the wrong
+background — you would see through them to whatever happened to be
+painted already, not to what is actually behind.
The Z value represents distance from the camera after transformation.
Larger values = further away. The id tiebreaker keeps the order
Each tile task:
-1. *Clear tile*: fill its rectangle with background color
-2. *Paint shapes*: rasterize the tile's bin, back-to-front, clipping
- at tile bounds
+1. *Clear tile*: fill its rectangle with background color and reset
+ its slice of the depth buffer
+2. *Paint shapes*: rasterize the tile's bin in two passes — first
+ opaque-class shapes front-to-back (depth test + write, so covered
+ pixels are rejected before their texture is fetched), then
+ alpha-class shapes back-to-front (depth test, no write, so
+ translucency blends painter-coherently). All rasterization clips
+ at tile bounds. See [[file:Depth buffer/][Depth buffer]] for the
+ two-pass contract.
Both operations happen within the same task, so clearing always
completes before painting on that tile. Parallel clearing across
--- /dev/null
+<svg viewBox="0 0 720 300" width="720" height="300" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ </defs>
+
+ <!-- background -->
+ <rect width="720" height="300" fill="#061018"/>
+
+ <text x="360" y="30" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">Six gestures, six camera axes</text>
+
+ <g font-family="monospace">
+ <!-- header -->
+ <text x="60" y="62" fill="#999" font-size="10">cap gesture</text>
+ <text x="330" y="62" fill="#999" font-size="10">camera</text>
+ <line x1="55" y1="70" x2="668" y2="70" stroke="#1a3a4a" stroke-width="1"/>
+
+ <text x="60" y="92" fill="#c05088" font-size="10">push cap right / left</text>
+ <text x="330" y="92" fill="#40b0d0" font-size="10">strafe right / left (world X)</text>
+ <line x1="55" y1="100" x2="668" y2="100" stroke="#1a3a4a" stroke-width="0.5"/>
+
+ <text x="60" y="122" fill="#c05088" font-size="10">push cap away / toward you</text>
+ <text x="330" y="122" fill="#40b0d0" font-size="10">forward / backward (world Z)</text>
+ <line x1="55" y1="130" x2="668" y2="130" stroke="#1a3a4a" stroke-width="0.5"/>
+
+ <text x="60" y="152" fill="#c05088" font-size="10">press cap down / pull up</text>
+ <text x="330" y="152" fill="#40b0d0" font-size="10">down / up (world Y)</text>
+ <line x1="55" y1="160" x2="668" y2="160" stroke="#1a3a4a" stroke-width="0.5"/>
+
+ <text x="60" y="182" fill="#c05088" font-size="10">twist clockwise / counter-clockwise</text>
+ <text x="330" y="182" fill="#40b0d0" font-size="10">yaw right / left (2x sensitivity)</text>
+ <line x1="55" y1="190" x2="668" y2="190" stroke="#1a3a4a" stroke-width="0.5"/>
+
+ <text x="60" y="212" fill="#c05088" font-size="10">tilt top away / toward you</text>
+ <text x="330" y="212" fill="#40b0d0" font-size="10">look up / down</text>
+ <line x1="55" y1="220" x2="668" y2="220" stroke="#1a3a4a" stroke-width="0.5"/>
+
+ <text x="60" y="242" fill="#b09020" font-size="10">left cap button</text>
+ <text x="330" y="242" fill="#b09020" font-size="10">brake: kill camera velocity</text>
+ </g>
+
+ <text x="60" y="282" fill="#999" font-size="9" font-family="monospace">cap deflection = velocity - push harder to fly faster, release to stop instantly</text>
+</svg>
--- /dev/null
+:PROPERTIES:
+:CUSTOM_ID: spacemouse-6dof
+:END:
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: SpaceMouse 6DOF - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \setlength{\parindent}{15pt}
+#+LATEX_HEADER: \usepackage{palatino}
+#+LATEX_HEADER: \usepackage{charter}
+#+HTML_HEAD: <style type="text/css">body { max-width: 100%;}</style>
+
+[[file:../index.html#outline-container-spacemouse-6dof][<- Back to index]]
+
+* What the SpaceNavigator adds
+:PROPERTIES:
+:CUSTOM_ID: what-spacemouse-adds
+:END:
+
+Keyboard and mouse move the camera one axis at a time. A 3Dconnexion
+*SpaceNavigator* — a pressure-sensitive 6DOF cap — moves it on all
+six axes at once: push, pull, slide and press the cap to translate,
+tilt and twist it to rotate. The engine supports it out of the box:
+when a SpaceNavigator is plugged in over USB, =ViewPanel=
+automatically starts a =SpaceMouseManager= that discovers the device
+over hidraw (hot-plug works at runtime), and every application and
+demo flies the camera with zero setup. Unplugging returns control to
+mouse and keyboard. Disable with =-De3d.spacemouse=false=.
+
+The package lives in =eu.svjatoslav.aukio.e3d.gui.spacemouse=.
+
+* How the cap maps to the camera
+:PROPERTIES:
+:CUSTOM_ID: cap-mapping
+:END:
+
+The cap is *pressure-sensing*, so translation is direct proportional
+drive: cap deflection maps to camera velocity (full deflection =
+=TRANSLATION_SPEED_FACTOR= — 3.0 — times the camera speed limit),
+applied to the camera position every frame along the camera's own
+orientation. There is deliberately no acceleration integrator like
+the keyboard has: releasing the cap stops the camera instantly. Push
+harder to fly faster, nudge to creep.
+
+Rotation is split by axis: *twist* (yaw) is tuned twice as sensitive
+as *tilt* (pitch) — constants =ROTATION_YAW_DEGREES= 3.0 vs
+=ROTATION_PITCH_DEGREES= 1.5 in =SpaceMouseController=.
+
+#+CAPTION: Every cap gesture and the camera motion it drives. Sign conventions were verified live against the device.
+[[file:SpaceMouse gestures.svg]]
+
+The *left cap button* is a brake: it zeroes the movement vector
+accumulated by keyboard and scroll-wheel input (the cap itself needs
+no brake — it stops when you let go).
+
+* Composing with other input
+:PROPERTIES:
+:CUSTOM_ID: composing
+:END:
+
+SpaceMouse, head tracking and mouse drag all write the same camera
+and coexist without fighting. =HeadLookController= folds external
+camera writes into its base orientation every frame, so a SpaceMouse
+rotation or a mouse drag stays where you left it instead of being
+overwritten by the next head-tracking update. All three can be used
+interchangeably mid-motion.
+
+* Device access (udev rule)
+:PROPERTIES:
+:CUSTOM_ID: udev
+:END:
+
+Device access needs a udev rule that makes the SpaceNavigator's
+hidraw node writable by the session user — without it the node is
+root-only and the cap is never discovered. The rule ships in the
+engine repository as
+[[file:../../udev/99-spacenavigator.rules][udev/99-spacenavigator.rules]]:
+
+#+BEGIN_SRC text
+SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c626", MODE="0666"
+#+END_SRC
+
+Install it from the repository root and replug the device:
+
+#+BEGIN_SRC sh
+sudo install -m 644 udev/99-spacenavigator.rules /etc/udev/rules.d/
+sudo udevadm control --reload
+#+END_SRC
+
+The device is *silent at rest* — it only sends reports while the cap
+is deflected — so there is no liveness watchdog; an unplug is noticed
+on the next failed read.
+
+* Related classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class | Role in SpaceMouse support |
+|------------------------+------------------------------------------------------------|
+| =SpaceMouseManager= | auto-start with ViewPanel, hot-plug scanning |
+| =SpaceNavigatorHid= | hidraw transport: 7-byte translation/rotation/button reports |
+| =SpaceMouseController= | cap axes -> camera velocity/rotation, tuning constants |
+
+See the *SpaceMouse 6DOF* demo in the aukio-3d-demos project
+(=examples/spacemouse_demo/SpaceMouseDemo=) for a compass ring with a
+live six-axis readout built to verify the mapping above.
+
+[[file:../index.html#outline-container-spacemouse-6dof][Back to main documentation]]
--- /dev/null
+<svg viewBox="0 0 720 220" width="720" height="220" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ <marker id="ht-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#40b0d0"/>
+ </marker>
+ </defs>
+
+ <!-- background -->
+ <rect width="720" height="220" fill="#061018"/>
+
+ <text x="360" y="30" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">Head tracking: the glasses drive the camera</text>
+ <text x="360" y="50" fill="#999" font-size="10" font-family="monospace" text-anchor="middle">RayNeo XR glasses IMU over hidraw - auto-starts with ViewPanel, hot-plug at runtime</text>
+
+ <!-- pipeline boxes -->
+ <g font-family="monospace">
+ <rect x="20" y="86" width="140" height="64" rx="5" fill="rgba(57,255,20,0.07)" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="90" y="112" fill="#39FF14" font-size="11" text-anchor="middle">RayNeo glasses</text>
+ <text x="90" y="130" fill="#999" font-size="9" text-anchor="middle">gyro + accel, ~500 Hz</text>
+
+ <rect x="200" y="86" width="140" height="64" rx="5" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="270" y="112" fill="#40b0d0" font-size="11" text-anchor="middle">RayNeoHid</text>
+ <text x="270" y="130" fill="#999" font-size="9" text-anchor="middle">hidraw, 64-byte reports</text>
+
+ <rect x="380" y="86" width="140" height="64" rx="5" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5"/>
+ <text x="450" y="112" fill="#c05088" font-size="11" text-anchor="middle">HeadTracker</text>
+ <text x="450" y="130" fill="#999" font-size="9" text-anchor="middle">gyro + gravity fusion</text>
+
+ <rect x="560" y="86" width="140" height="64" rx="5" fill="rgba(176,144,32,0.07)" stroke="#b09020" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="630" y="112" fill="#b09020" font-size="11" text-anchor="middle">HeadLookController</text>
+ <text x="630" y="130" fill="#999" font-size="9" text-anchor="middle">yaw / pitch -> camera</text>
+
+ <line x1="162" y1="118" x2="196" y2="118" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#ht-arr)"/>
+ <line x1="342" y1="118" x2="376" y2="118" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#ht-arr)"/>
+ <line x1="522" y1="118" x2="556" y2="118" stroke="#40b0d0" stroke-width="1.5" marker-end="url(#ht-arr)"/>
+ </g>
+
+ <text x="360" y="186" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">HeadTrackingManager: auto-start with ViewPanel, hot-plug, frozen-stream watchdog</text>
+ <text x="360" y="202" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">Scroll Lock recenters yaw drift - mouse drag and SpaceMouse compose on top of head look</text>
+</svg>
-<svg viewBox="0 0 640 300" width="640" height="300" xmlns="http://www.w3.org/2000/svg">
+<svg viewBox="0 0 720 300" width="720" height="300" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
</defs>
<!-- background -->
- <rect width="640" height="300" fill="#061018"/>
+ <rect width="720" height="300" fill="#061018"/>
- <text x="320" y="30" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">What changes per eye</text>
+ <text x="360" y="30" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">What changes per eye</text>
<!-- table-ish rows -->
<g font-family="monospace">
<!-- header -->
<text x="60" y="62" fill="#999" font-size="10">concern</text>
<text x="330" y="62" fill="#999" font-size="10">per-eye behavior</text>
- <line x1="55" y1="70" x2="600" y2="70" stroke="#1a3a4a" stroke-width="1"/>
+ <line x1="55" y1="70" x2="668" y2="70" stroke="#1a3a4a" stroke-width="1"/>
<text x="60" y="92" fill="#c05088" font-size="10">camera</text>
<text x="330" y="92" fill="#40b0d0" font-size="10">translation.x += ±IPD/2 (restored after the pass)</text>
- <line x1="55" y1="100" x2="600" y2="100" stroke="#1a3a4a" stroke-width="0.5"/>
+ <line x1="55" y1="100" x2="668" y2="100" stroke="#1a3a4a" stroke-width="0.5"/>
<text x="60" y="122" fill="#c05088" font-size="10">projection</text>
<text x="330" y="122" fill="#40b0d0" font-size="10">scale = eyeWidth/3; x += stereoViewportOffsetX</text>
- <line x1="55" y1="130" x2="600" y2="130" stroke="#1a3a4a" stroke-width="0.5"/>
+ <line x1="55" y1="130" x2="668" y2="130" stroke="#1a3a4a" stroke-width="0.5"/>
<text x="60" y="152" fill="#c05088" font-size="10">frustum culling</text>
<text x="330" y="152" fill="#40b0d0" font-size="10">built from stereoViewportWidth - narrower FOV per eye</text>
- <line x1="55" y1="160" x2="600" y2="160" stroke="#1a3a4a" stroke-width="0.5"/>
+ <line x1="55" y1="160" x2="668" y2="160" stroke="#1a3a4a" stroke-width="0.5"/>
<text x="60" y="182" fill="#c05088" font-size="10">painting</text>
<text x="330" y="182" fill="#40b0d0" font-size="10">clipped to [renderMinX, renderMaxX) = the eye's half</text>
- <line x1="55" y1="190" x2="600" y2="190" stroke="#1a3a4a" stroke-width="0.5"/>
+ <line x1="55" y1="190" x2="668" y2="190" stroke="#1a3a4a" stroke-width="0.5"/>
<text x="60" y="212" fill="#c05088" font-size="10">mouse picking</text>
<text x="330" y="212" fill="#40b0d0" font-size="10">hits combined only for the eye containing the cursor</text>
- <line x1="55" y1="220" x2="600" y2="220" stroke="#1a3a4a" stroke-width="0.5"/>
+ <line x1="55" y1="220" x2="668" y2="220" stroke="#1a3a4a" stroke-width="0.5"/>
<text x="60" y="242" fill="#c05088" font-size="10">HUD / overlays</text>
<text x="330" y="242" fill="#40b0d0" font-size="10">drawn once, spanning the full frame (zero disparity)</text>
#+CAPTION: The same scene rendered as a normal mono frame (for comparison with the pair above). Notice there is no horizontal offset to read depth from — the picture is flat.
[[file:mono-comparison.png]]
+* Head tracking: the glasses also drive the camera
+:PROPERTIES:
+:CUSTOM_ID: head-tracking
+:END:
+
+Side-by-side stereo covers the *display* half of XR glasses. The
+*input* half — turning the camera with your head — lives in the
+engine too, in the =eu.svjatoslav.aukio.e3d.gui.headtrack= package,
+so every application and demo gets it with zero setup. When a pair
+of RayNeo XR glasses is plugged in over USB, =ViewPanel=
+automatically starts a =HeadTrackingManager= that discovers the
+device over hidraw: tracking begins within a couple of seconds of
+plug-in (hot-plug works at runtime), and unplugging returns control
+to mouse and keyboard. Disable with =-De3d.headtrack=false=.
+
+#+CAPTION: The head-tracking data path. The glasses stream IMU samples over hidraw; the tracker fuses them into yaw/pitch angles that the controller applies to the camera every frame.
+[[file:Head tracking.svg]]
+
+=HeadTracker= fuses the glasses' IMU with a complementary filter:
+gyroscope rates integrate into yaw/pitch angles (integration time
+comes from the wall clock, never the device tick), while the
+accelerometer's gravity vector slowly pulls pitch toward the true
+horizon. Yaw has no absolute reference and drifts slowly — *Scroll
+Lock* recenters at any time. Hold still for a second after plug-in:
+gyro-bias calibration only samples while the head is stationary, so
+turning during the first moment poisons it with phantom drift.
+
+=HeadLookController= applies the fused angles to the camera every
+frame (yaw gain 2.0, pitch gain 1.0; roll is deliberately not
+applied), so the virtual world stays put while you look around.
+Mouse drag composes on top — drag to look further than your neck
+turns, and the view stays where the mouse left it; a SpaceNavigator
+6DOF cap composes the same way.
+
+Combined with stereo this is the full XR setup: *SHIFT+F11* gives
+the glasses their side-by-side image, and the same glasses' IMU
+drives the camera. Note that the world-X IPD limitation listed below
+now applies to *head* motion: the eye offset is exact while facing
+along Z and degrades at large yaw angles.
+
+Practical notes:
+
+- Only one process may use the glasses at a time — concurrent
+ consumers send conflicting IMU on/off commands and starve each
+ other's streams.
+- Head tracking is the engine's one sanctioned native dependency: it
+ talks to hidraw through JNA.
+- Device access needs the udev rule shown below — without it the
+ glasses' hidraw node is root-only and tracking never starts.
+
+The rule ships in the engine repository as
+[[file:../../udev/99-rayneo-glasses.rules][udev/99-rayneo-glasses.rules]]:
+
+#+BEGIN_SRC text
+ACTION=="add|change", SUBSYSTEM=="hidraw", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1bbb", ATTRS{idProduct}=="af50", MODE="0666"
+#+END_SRC
+
+Install it from the repository root and replug the glasses:
+
+#+BEGIN_SRC sh
+sudo install -m 644 udev/99-rayneo-glasses.rules /etc/udev/rules.d/
+sudo udevadm control --reload
+#+END_SRC
+
+| Class | Role in head tracking |
+|------------------------+----------------------------------------------------------|
+| =HeadTrackingManager= | auto-start with ViewPanel, hot-plug, stream watchdog |
+| =RayNeoHid= | hidraw transport: enable sequence, 64-byte IMU reports |
+| =HeadTracker= | IMU fusion, stationary-gated calibration, drift control |
+| =HeadLookController= | applies fused yaw/pitch to the camera, folds mouse input |
+
* Performance and limitations
:PROPERTIES:
:CUSTOM_ID: limitations
--- /dev/null
+<svg viewBox="0 0 720 330" width="720" height="330" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ </defs>
+
+ <rect width="720" height="330" fill="#061018"/>
+
+ <text x="360" y="28" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">The culling ladder: work discarded as early as possible</text>
+
+ <g font-family="monospace" font-size="10">
+ <rect x="60" y="52" width="620" height="28" fill="rgba(32,112,192,0.10)" stroke="#2070c0" stroke-width="1.5"/>
+ <text x="70" y="71" fill="#2070c0">scene: every shape in the world</text>
+
+ <rect x="60" y="94" width="520" height="28" fill="rgba(57,255,20,0.07)" stroke="#39FF14" stroke-width="1.5"/>
+ <text x="70" y="113" fill="#39FF14">1. composite frustum culling - skip invisible subtrees</text>
+
+ <rect x="60" y="136" width="430" height="28" fill="rgba(64,176,208,0.07)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="70" y="155" fill="#40b0d0">2. screen-bounds culling - drop off-frame shapes</text>
+
+ <rect x="60" y="178" width="340" height="28" fill="rgba(221,153,0,0.09)" stroke="#dd9900" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="70" y="197" fill="#dd9900">3. subpixel culling - drop shapes smaller than a pixel</text>
+ <text x="412" y="197" fill="#dd9900" font-size="9" filter="url(#glow)">← THIS PAGE</text>
+
+ <rect x="60" y="220" width="250" height="28" fill="rgba(192,80,136,0.07)" stroke="#c05088" stroke-width="1.5"/>
+ <text x="70" y="239" fill="#c05088">4. Hi-Z occlusion - drop hidden blocks</text>
+
+ <rect x="60" y="262" width="160" height="28" fill="rgba(57,255,20,0.10)" stroke="#39FF14" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="70" y="281" fill="#39FF14">what remains paints</text>
+ </g>
+
+ <text x="60" y="316" fill="#999" font-size="9" font-family="monospace">stages 1-3 run inside transform; Hi-Z tests whole mesh blocks against last frame's depth pyramid</text>
+</svg>
--- /dev/null
+<svg viewBox="0 0 720 290" width="720" height="290" xmlns="http://www.w3.org/2000/svg">
+ <defs>
+ <filter id="glow">
+ <feGaussianBlur stdDeviation="1.5" result="blur"/>
+ <feMerge>
+ <feMergeNode in="blur"/>
+ <feMergeNode in="SourceGraphic"/>
+ </feMerge>
+ </filter>
+ </defs>
+
+ <rect width="720" height="290" fill="#061018"/>
+
+ <text x="360" y="28" fill="#40b0d0" font-size="15" font-family="monospace" text-anchor="middle" filter="url(#glow)">The verdict cache: skip setup, not just drawing</text>
+
+ <!-- epoch spans -->
+ <g font-family="monospace">
+ <rect x="60" y="130" width="240" height="30" fill="rgba(57,255,20,0.08)" stroke="#39FF14" stroke-width="1.5"/>
+ <text x="180" y="149" fill="#39FF14" font-size="10" text-anchor="middle">epoch 7: cached skips</text>
+ <rect x="302" y="130" width="218" height="30" fill="rgba(64,176,208,0.08)" stroke="#40b0d0" stroke-width="1.5"/>
+ <text x="411" y="149" fill="#40b0d0" font-size="10" text-anchor="middle">epoch 8: re-evaluate once,</text>
+ <rect x="522" y="130" width="158" height="30" fill="rgba(192,80,136,0.08)" stroke="#c05088" stroke-width="1.5"/>
+ <text x="601" y="149" fill="#c05088" font-size="10" text-anchor="middle">epoch 9: ...</text>
+ </g>
+
+ <!-- bump ticks -->
+ <line x1="301" y1="110" x2="301" y2="200" stroke="#FF4444" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="301" y="102" fill="#FF4444" font-size="8" font-family="monospace" text-anchor="middle">camera moved > 25 units</text>
+ <line x1="521" y1="110" x2="521" y2="200" stroke="#FF4444" stroke-width="1.5" filter="url(#glow)"/>
+ <text x="521" y="102" fill="#FF4444" font-size="8" font-family="monospace" text-anchor="middle">rotation > ~1.1 degrees</text>
+
+ <!-- frame axis -->
+ <line x1="60" y1="200" x2="680" y2="200" stroke="#999" stroke-width="1"/>
+ <g stroke="#999" stroke-width="1">
+ <line x1="60" y1="196" x2="60" y2="204"/><line x1="137" y1="196" x2="137" y2="204"/><line x1="215" y1="196" x2="215" y2="204"/>
+ <line x1="292" y1="196" x2="292" y2="204"/><line x1="370" y1="196" x2="370" y2="204"/><line x1="447" y1="196" x2="447" y2="204"/>
+ <line x1="525" y1="196" x2="525" y2="204"/><line x1="602" y1="196" x2="602" y2="204"/><line x1="680" y1="196" x2="680" y2="204"/>
+ </g>
+ <text x="370" y="218" fill="#999" font-size="9" font-family="monospace" text-anchor="middle">frames →</text>
+
+ <text x="411" y="175" fill="#999" font-size="8" font-family="monospace" text-anchor="middle">then cached again</text>
+
+ <text x="60" y="244" fill="#999" font-size="9" font-family="monospace">while the epoch holds, a culled shape returns at the top of transform() - no vertex math at all</text>
+ <text x="60" y="262" fill="#999" font-size="9" font-family="monospace">the epoch also bumps unconditionally every 30 frames (-Daukio.cull.subpixel.frames)</text>
+ <text x="60" y="280" fill="#999" font-size="9" font-family="monospace">a culled shape is re-evaluated whenever the camera could have made it visible again</text>
+</svg>
--- /dev/null
+:PROPERTIES:
+:CUSTOM_ID: subpixel-culling
+:END:
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Subpixel Culling - Aukio 3D
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \setlength{\parindent}{15pt}
+#+LATEX_HEADER: \usepackage{palatino}
+#+LATEX_HEADER: \usepackage{charter}
+#+HTML_HEAD: <style type="text/css">body { max-width: 100%;}</style>
+
+[[file:../index.html#outline-container-subpixel-culling][<- Back to index]]
+
+* Shapes too small to see
+:PROPERTIES:
+:CUSTOM_ID: too-small-to-see
+:END:
+
+A distant city contains thousands of triangles whose entire screen
+footprint is a fraction of a pixel. They cannot influence the image —
+but the pipeline still transforms their vertices, clips them against
+the near plane, computes their bounds and queues them for sorting.
+
+*Subpixel culling* drops them at the earliest possible moment. It is
+off by default; enable it with a threshold in pixels:
+
+#+BEGIN_SRC sh
+java -Daukio.cull.subpixel=0.4 -jar my-app.jar
+#+END_SRC
+
+The verdict is evaluated in =AbstractCoordinateShape.transform()=,
+immediately after the shape's screen-space bounds are known and
+*before* the shape is queued: if the raw span (=maxX - minX= and
+=maxY - minY=, before any paint margins) is below the threshold on
+*both* axes, the shape is dropped for this pass. The bounds were
+already computed for the other culling stages, so the test itself is
+nearly free.
+
+Subpixel culling is one rung of the engine's culling ladder — each
+rung discards work earlier and more cheaply than the previous one
+could have:
+
+#+CAPTION: The culling ladder. Subpixel culling is the third rung: frustum culling skips whole invisible subtrees, screen-bounds culling drops individual off-frame shapes, subpixel culling drops the ones too small to matter, and Hi-Z removes occluded mesh blocks.
+[[file:Culling ladder.svg]]
+
+* The verdict cache: skip setup, not just drawing
+:PROPERTIES:
+:CUSTOM_ID: verdict-cache
+:END:
+
+Re-testing every tiny shape every frame would still walk the scene
+graph and run per-shape setup. So once a shape is culled, the
+*verdict itself is cached* on the shape (a flag plus the epoch it was
+culled in). While the epoch still matches, =transform()= returns at
+the very top — no vertex transforms, no clipping, no bounds loop at
+all. Tiny geometry costs literally nothing on stationary frames.
+
+The epoch is owned by =ShapeCollection= and advances exactly when a
+culled shape could have become visible again:
+
+- the camera *translated* more than =-Daukio.cull.subpixel.translate=
+ (default 25 world units), or
+- the camera *rotated* (quaternion component delta over
+ =-Daukio.cull.subpixel.rotate=, default 0.01 ≈ 1.1°), or
+- =-Daukio.cull.subpixel.frames= frames elapsed (default 30) — an
+ unconditional bump that catches shape-side transform changes.
+
+#+CAPTION: The epoch timeline. Cached verdicts are trusted while the camera stays put; any significant camera change re-evaluates every culled shape once, then the cache settles again.
+[[file:Verdict epoch.svg]]
+
+One safety note for engine work: the cached skip leaves the shape's
+per-slot vertex state stale. That is safe only because unqueued
+shapes are never painted — any future code path that reads per-slot
+state for a shape that was not queued will see stale data.
+
+* Tuning
+:PROPERTIES:
+:CUSTOM_ID: tuning
+:END:
+
+| Property | Default | Meaning |
+|-------------------------------+---------+---------|
+| =aukio.cull.subpixel= | 0 (off) | screen-span threshold in pixels; shapes below it on both axes are culled |
+| =aukio.cull.subpixel.translate= | 25 | camera translation (world units) that invalidates the cache |
+| =aukio.cull.subpixel.rotate= | 0.01 | camera rotation (quaternion delta ≈ 1.1°) that invalidates the cache |
+| =aukio.cull.subpixel.frames= | 30 | unconditional invalidation period in frames |
+
+Measured on the Fallout 4 environment's downtown scene (a
+triangle-heavy skyline at altitude): at 0.4 px the paint queue
+shrank ~13% (525k to 456k shapes) and frame time dropped from ~135
+to ~125 ms, with golden-image diffs under 0.006% of pixels — visually
+indistinguishable. The bigger win is the verdict cache on stationary
+frames, where culled shapes cost no setup at all.
+
+Start at 0.4–0.5 px and raise only if artifacts are absent: too
+large a threshold eats small lights, antennae and text at mid
+distances.
+
+* Related classes
+:PROPERTIES:
+:CUSTOM_ID: related-classes
+:END:
+
+| Class | Role in subpixel culling |
+|-----------------------------+-----------------------------------------------------|
+| =AbstractCoordinateShape= | evaluates the verdict; caches flag + epoch |
+| =ShapeCollection= | owns and advances the epoch |
+| =RenderingContext= | carries the threshold and the epoch into each pass |
+
+See also: [[file:Frustum culling/][Frustum culling]] (rung 1),
+[[file:Depth%20buffer/][Depth buffer]] (the Hi-Z pyramid, rung 4).
+
+[[file:../index.html#outline-container-subpixel-culling][Back to main documentation]]
pipeline, each clipped to its half of the frame buffer; per-eye
projection, frustum culling and mouse picking adapt automatically.
+With RayNeo XR glasses the engine also tracks the head: plugging the
+glasses in over USB auto-starts IMU head tracking, so the stereo image
+responds to look-around (hot-plug at runtime, Scroll Lock recenters).
+
See [[file:Stereoscopic%20rendering/][Stereoscopic rendering]] for the
-geometry, pipeline and tuning.
+geometry, pipeline and tuning, and its
+[[file:Stereoscopic%20rendering/index.org::#head-tracking][head tracking]]
+section for the glasses IMU support.
+
+** SpaceMouse 6DOF input
+:PROPERTIES:
+:CUSTOM_ID: spacemouse-6dof
+:END:
+
+#+attr_html: :width 600px
+#+attr_latex: :width 600px
+[[file:SpaceMouse 6DOF/SpaceMouse.png]]
+
+
+A 3Dconnexion SpaceNavigator flies the camera with its
+pressure-sensitive 6DOF cap: push, pull, slide and press to move,
+tilt and twist to look. Plugging it in auto-starts camera control in
+every application (hot-plug at runtime), composing freely with head
+tracking and mouse drag.
+
+See [[file:SpaceMouse%206DOF/][SpaceMouse 6DOF]] for the cap mapping,
+tuning and device setup.
** Constructive Solid Geometry
:PROPERTIES:
See [[file:SDF%20textures/][SDF textures]] for the glyph pipeline, the
render path, analytic minification and tuning knobs.
+** Octree voxel volumes and ray tracing
+:PROPERTIES:
+:CUSTOM_ID: octree-ray-tracing
+:END:
+
+[[file:Octree ray tracing/Octree subdivision.svg]]
+
+Beyond triangles, the engine can store a scene as a *voxel volume* in
+a flat-pool octree — and render it with a real ray tracer: one ray
+per pixel through the volume, per-pixel shadows from shadow rays, and
+progressive on-screen refinement.
+
+See [[file:Octree%20ray%20tracing/][Octree ray tracing]] for the data
+structure, the traversal and how to run it.
+
+** Interactive GUI components
+:PROPERTIES:
+:CUSTOM_ID: gui-components
+:END:
+
+[[file:GUI components/Event dispatch.svg]]
+
+Widgets live *in the world*: clickable, focusable panels that receive
+keyboard input, with perspective-correct texture coordinates for
+every click and hover. A complete text editor is built on the same
+base class.
+
+See [[file:GUI%20components/][GUI components]] for the focus model,
+event dispatch and the callback surface.
+
+** Wavefront OBJ model loading
+:PROPERTIES:
+:CUSTOM_ID: obj-loading
+:END:
+
+Existing models in the [[https://en.wikipedia.org/wiki/Wavefront_.obj_file][Wavefront OBJ]] format load
+into the scene graph with one call:
+
+#+BEGIN_SRC java
+ObjModel model = ObjLoader.load(Path.of("city1.obj"));
+model.setShadingEnabled(true);
+shapes.addShape(model);
+#+END_SRC
+
+=ObjLoader= (package
+=eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj=)
+parses vertices, polygonal faces (all index forms, including forward
+references from batch-flushing exporters), =usemtl=/=mtllib= material
+references, and MTL =Kd= diffuse color + =d= opacity (translucent
+materials blend in the alpha pass). Faces become [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.html][SolidPolygon]]
+instances inside an =ObjModel= composite, so transforms, shading and
+backface culling work as for any composite. Coordinates load verbatim;
+for standard +Y-up exports rotate the model 180° around X to stand
+upright in the engine's -Y-up world.
+
+If the source units do not match the scene's world scale, resize the
+model with the transform's uniform scale instead of editing the file —
+it applies about the model's local origin, before rotation:
+
+#+BEGIN_SRC java
+model.setTransform(Transform.fromAngles(0, 0, 0, 0, Math.PI, 0));
+model.getTransform().setScale(5.0); // grow 5x
+#+END_SRC
+
+=Transform.setScale= works on any shape, not just loaded models: scale
+is folded into the composed transform stack at push time, so per-vertex
+render cost is unchanged and scenes that never set a scale render
+bit-identically. Negative values mirror the geometry (flipping face
+winding and thus backface culling).
+
+See the [[https://www3.svjatoslav.eu/projects/aukio-3d-demos/#obj-loader-city][OBJ loader city demo]] for a complete example.
+
* How take engine into use
:PROPERTIES:
:CUSTOM_ID: taking-engine-into-use
- See [[https://www3.svjatoslav.eu/projects/aukio-3d/graphs/][*Aukio 3D* class diagrams]]. (Diagrams were generated by using
[[https://www3.svjatoslav.eu/projects/javainspect/][JavaInspect]] utility)
+** Configuration
+:PROPERTIES:
+:CUSTOM_ID: configuration
+:END:
+
+All Aukio programs share one YAML file, =~/.config/aukio/config.yaml=.
+Engine settings live in its =e3d:= section — stereo IPD, log and bug
+report directories, telemetry interval — and every key can be
+overridden with a =-D= system property for a single run.
+
+See [[file:Configuration/][Configuration]] for the key table, live
+reload and write safety.
+
* Essential theory
:PROPERTIES:
:CUSTOM_ID: understanding-3d-engine
To understand frustum culling and object-level visibility
optimization, read more about [[file:Frustum culling/][frustum & view frustum culling.]]
+** Subpixel culling
+:PROPERTIES:
+:CUSTOM_ID: subpixel-culling
+:END:
+
+Distant geometry whose whole screen footprint is a fraction of a
+pixel still costs full transform setup. *Subpixel culling* drops such
+shapes right after their screen bounds are known, and caches the
+verdict per shape so that stationary frames skip their setup
+entirely. Off by default; enable with =-Daukio.cull.subpixel=<px>=.
+
+#+INCLUDE: "Subpixel culling/Culling ladder.svg" export html
+
+Read more about [[file:Subpixel%20culling/][subpixel culling]] — the
+verdict epoch, tuning knobs and measured effect.
+
** Winding Order & Backface Culling
:PROPERTIES:
:CUSTOM_ID: winding-order-backface-culling
final int threadCount) {
if (globalIllumination == null) {
globalIllumination = new eu.svjatoslav.aukio.e3d.renderer.raster.gi.GlobalIllumination(
- rootShapeCollection, lightingManager, threadCount);
+ rootShapeCollection, lightingManager, threadCount,
+ () -> getCamera().getTransform().getTranslation());
globalIllumination.start();
}
return globalIllumination;
import eu.svjatoslav.aukio.e3d.geometry.Point3D;
/**
- * Represents a transformation in 3D space combining translation and rotation.
+ * Represents a transformation in 3D space combining uniform scale, rotation,
+ * and translation (a similarity transform).
*
- * <p>Transformations are applied in order: rotation first, then translation.</p>
+ * <p>Transformations are applied in order: scale first (about the local
+ * origin), then rotation, then translation.</p>
*
* <p><b>Performance optimization:</b> The rotation matrix is cached and only
* recomputed when the rotation quaternion changes. This avoids allocating a
*/
private final Quaternion rotation;
+ /**
+ * Uniform scale applied before rotation, about the local origin.
+ * Defaults to 1.0 (no scaling). Negative values mirror the geometry
+ * (and so flip face winding / backface culling).
+ */
+ private double scale = 1.0;
+
/**
* Cached rotation matrix for performance.
* Lazily computed when first needed and reused for subsequent transform() calls.
}
/**
- * Creates a copy of this transform with cloned translation and rotation.
+ * Creates a copy of this transform with cloned translation and rotation,
+ * preserving the scale.
*
- * @return a new transform with the same translation and rotation values
+ * @return a new transform with the same translation, rotation and scale values
*/
@Override
public Transform clone() {
- return new Transform(translation, rotation);
+ final Transform copy = new Transform(translation, rotation);
+ copy.scale = scale;
+ return copy;
+ }
+
+ /**
+ * Returns the uniform scale applied before rotation (about the local
+ * origin). Default is 1.0 (no scaling).
+ *
+ * @return the scale factor
+ */
+ public double getScale() {
+ return scale;
+ }
+
+ /**
+ * Sets the uniform scale, applied before rotation about the local origin.
+ *
+ * <p>Values other than 1.0 resize the geometry in its local space.
+ * Negative values additionally mirror the geometry, which flips face
+ * winding and therefore inverts backface culling. Zero is rejected
+ * because it would collapse all geometry onto the translation point.</p>
+ *
+ * <p>Like the rest of this class, the new value is picked up by a
+ * {@link TransformStack} only on the next push (push-time snapshot
+ * contract).</p>
+ *
+ * @param scale the scale factor (must be finite and non-zero)
+ * @return this transform (for chaining)
+ */
+ public Transform setScale(final double scale) {
+ if (!Double.isFinite(scale) || scale == 0.0) {
+ throw new IllegalArgumentException(
+ "scale must be finite and non-zero, got " + scale);
+ }
+ this.scale = scale;
+ return this;
}
/**
}
/**
- * Applies this transform to a point: rotation followed by translation.
+ * Applies this transform to a point: uniform scale (about the local
+ * origin), then rotation, then translation.
*
* <p>Uses a cached rotation matrix to avoid allocation and redundant computation.
* The matrix is computed once (lazily) and reused for all subsequent calls
* until {@link #invalidateCache()} is called.</p>
*
+ * <p>With the default scale of 1.0 the multiply is skipped, so existing
+ * rigid-motion usage produces bit-identical results.</p>
+ *
* @param point the point to transform (modified in place)
* @see #withTransformed(Point3D) for the non-mutating version that returns a new point
*/
public void transform(final Point3D point) {
+ if (scale != 1.0) {
+ point.x *= scale;
+ point.y *= scale;
+ point.z *= scale;
+ }
getRotationMatrix().transform(point, point);
point.add(translation);
}
* 3. Apply ship's position relative to the world
* </pre>
*
- * <p><b>Implementation: eager composition.</b> Every transform is a rigid
- * motion (rotation then translation), and compositions of rigid motions are
- * closed and associative, so the whole stack collapses into a single
- * equivalent transform. The stack maintains the composed rotation matrix and
- * translation for each level: {@link #addTransform} composes the pushed
- * transform with the previous level's composite, and {@link #transform}
- * applies only the top-level composite. Per-point cost is one 3x3
- * matrix-vector multiply plus one addition, independent of stack depth.
- * {@link #dropTransform} restores the parent composite for free.</p>
+ * <p><b>Implementation: eager composition.</b> Every transform is a
+ * similarity transform (uniform scale, then rotation, then translation).
+ * Uniform scale commutes with rotation, so compositions stay closed and
+ * associative: the whole stack collapses into a single equivalent transform.
+ * The stack maintains the composed scale-rotation matrix and translation
+ * for each level: {@link #addTransform} folds the pushed transform's scale
+ * into the matrix and composes it with the previous level's composite, and
+ * {@link #transform} applies only the top-level composite. Per-point cost
+ * is one 3x3 matrix-vector multiply plus one addition, independent of stack
+ * depth. {@link #dropTransform} restores the parent composite for free.</p>
*
* <p><b>Contract:</b> composition is a snapshot taken at push time. Mutating
* a transform after pushing it has no effect on the stack until it is
* Pushes a transform onto the stack, composing it with the current
* top-level composite.
*
- * <p>If the previous composite is (Rp, tp) and the pushed transform is
- * (Rn, tn), the new composite is R' = Rp * Rn, t' = Rp * tn + tp —
- * the pushed transform applies first, then the previous composite.</p>
+ * <p>If the previous composite is (Mp, tp) and the pushed transform has
+ * scale s, rotation Rn and translation tn, the new composite is
+ * M' = Mp * (s * Rn), t' = Mp * tn + tp — the pushed transform applies
+ * first, then the previous composite. Because uniform scale commutes
+ * with rotation, folding s into the pushed matrix keeps the composition
+ * closed: the stored parent matrix already carries the parent's scale.
+ * With the default scale of 1.0 the extra multiply is IEEE-exact, so
+ * rigid-motion scenes stay bit-identical.</p>
*
* @param transform the transform to push (snapshotted at push time)
*/
final int v = i * 3;
final Matrix3x3 rm = transform.getRotationMatrix();
+ final double s = transform.getScale();
final Point3D t = transform.getTranslation();
if (i == 0) {
- rotations[r] = rm.m00;
- rotations[r + 1] = rm.m01;
- rotations[r + 2] = rm.m02;
- rotations[r + 3] = rm.m10;
- rotations[r + 4] = rm.m11;
- rotations[r + 5] = rm.m12;
- rotations[r + 6] = rm.m20;
- rotations[r + 7] = rm.m21;
- rotations[r + 8] = rm.m22;
+ rotations[r] = s * rm.m00;
+ rotations[r + 1] = s * rm.m01;
+ rotations[r + 2] = s * rm.m02;
+ rotations[r + 3] = s * rm.m10;
+ rotations[r + 4] = s * rm.m11;
+ rotations[r + 5] = s * rm.m12;
+ rotations[r + 6] = s * rm.m20;
+ rotations[r + 7] = s * rm.m21;
+ rotations[r + 8] = s * rm.m22;
translations[v] = t.x;
translations[v + 1] = t.y;
translations[v + 2] = t.z;
final double a21 = rotations[pr + 7];
final double a22 = rotations[pr + 8];
- rotations[r] = a00 * rm.m00 + a01 * rm.m10 + a02 * rm.m20;
- rotations[r + 1] = a00 * rm.m01 + a01 * rm.m11 + a02 * rm.m21;
- rotations[r + 2] = a00 * rm.m02 + a01 * rm.m12 + a02 * rm.m22;
- rotations[r + 3] = a10 * rm.m00 + a11 * rm.m10 + a12 * rm.m20;
- rotations[r + 4] = a10 * rm.m01 + a11 * rm.m11 + a12 * rm.m21;
- rotations[r + 5] = a10 * rm.m02 + a11 * rm.m12 + a12 * rm.m22;
- rotations[r + 6] = a20 * rm.m00 + a21 * rm.m10 + a22 * rm.m20;
- rotations[r + 7] = a20 * rm.m01 + a21 * rm.m11 + a22 * rm.m21;
- rotations[r + 8] = a20 * rm.m02 + a21 * rm.m12 + a22 * rm.m22;
+ rotations[r] = s * (a00 * rm.m00 + a01 * rm.m10 + a02 * rm.m20);
+ rotations[r + 1] = s * (a00 * rm.m01 + a01 * rm.m11 + a02 * rm.m21);
+ rotations[r + 2] = s * (a00 * rm.m02 + a01 * rm.m12 + a02 * rm.m22);
+ rotations[r + 3] = s * (a10 * rm.m00 + a11 * rm.m10 + a12 * rm.m20);
+ rotations[r + 4] = s * (a10 * rm.m01 + a11 * rm.m11 + a12 * rm.m21);
+ rotations[r + 5] = s * (a10 * rm.m02 + a11 * rm.m12 + a12 * rm.m22);
+ rotations[r + 6] = s * (a20 * rm.m00 + a21 * rm.m10 + a22 * rm.m20);
+ rotations[r + 7] = s * (a20 * rm.m01 + a21 * rm.m11 + a22 * rm.m21);
+ rotations[r + 8] = s * (a20 * rm.m02 + a21 * rm.m12 + a22 * rm.m22);
translations[v] = a00 * t.x + a01 * t.y + a02 * t.z + translations[pv];
translations[v + 1] = a10 * t.x + a11 * t.y + a12 * t.z + translations[pv + 1];
import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
/**
* Progressive CPU global illumination, running on dedicated low-priority
* threads (never on the render ForkJoinPool).
*
+ * <p><b>Phased scheduling (default, {@code -De3d.gi.phases=true}):</b>
+ * the scene is lit in three strict global phases, each ordered by polygon
+ * distance from the camera, near first:</p>
+ * <ol>
+ * <li><b>A — centroid direct:</b> each polygon gets one direct-light
+ * evaluation at its centroid (shadow rays to all lights). The
+ * result is stamped onto the whole polygon at once — the world
+ * lights up polygon by polygon as a visible near-to-far wave
+ * instead of staying at the uniform medium start until a full
+ * per-texel sweep completes (which does not scale to huge
+ * scenes).</li>
+ * <li><b>B — centroid multi-bounce:</b> bounce rays from centroids,
+ * still one sample point per polygon, feeding the indirect field.
+ * Cheap enough to converge for the whole scene before any texel
+ * work starts.</li>
+ * <li><b>C — per-texel refinement:</b> lightmapped triangles are
+ * refined near-to-far; a triangle entering this phase has its
+ * texels seeded from its converged centroid values, so detail
+ * glides in without a second dark age. A lightmap graduates once
+ * every texel has been re-sampled at least once — so no
+ * centroid-seeded shadow value survives — and its on-screen
+ * estimate has stopped moving; ray sampling then stops and a
+ * freeze tail keeps recompositing the now-fixed target until the
+ * on-screen estimate has fully glided in — without the tail,
+ * graduation would freeze a few light units of residual
+ * per-triangle stamp bias into the texture forever, visible as
+ * seams between adjacent triangles. When every lightmap has
+ * display-frozen, the workers idle.</li>
+ * </ol>
+ *
+ * <p>Phases B and C run on a bounded active window
+ * ({@code -De3d.gi.activeWindow}, default 256 entries) so near geometry
+ * converges before CPU is spent on far geometry. When the camera moves
+ * more than {@code -De3d.gi.resortDistance} (default 25 world units)
+ * the not-yet-activated work is re-sorted by distance to the new
+ * position. Scene or light changes rebuild the snapshot and restart
+ * from phase A. Set {@code -De3d.gi.phases=false} for the legacy flat
+ * round-robin scheduler.</p>
+ *
* <p><b>Two sampling resolutions:</b></p>
* <ul>
* <li><b>Lightmapped triangles</b> ({@link LightmappedShape}, e.g. the
* wrapped polygons of a lightmapping-enabled
* {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}):
- * per-texel sampling. Shadows and gradients live INSIDE the polygon
- * surface; the painted texture is the premultiplied composite
- * (baseColor x ambient+direct+indirect), regenerated on GI threads
- * and swapped in double-buffered — painters never see a half-updated
- * texture.</li>
- * <li><b>Plain solid polygons</b>: per-polygon sampling; the result feeds
- * the flat-shading path through {@link GiLightProvider} (shadow tests
- * + indirect add). Polygons stay single-colored.</li>
+ * per-texel sampling in phase C. Shadows and gradients live INSIDE
+ * the polygon surface; the painted texture is the premultiplied
+ * composite (baseColor x ambient+direct+indirect), regenerated on
+ * GI threads and swapped in double-buffered — painters never see a
+ * half-updated texture.</li>
+ * <li><b>Plain solid polygons</b>: per-polygon sampling (complete after
+ * phase B); the result feeds the flat-shading path through
+ * {@link GiLightProvider} (shadow tests + indirect add). Polygons
+ * stay single-colored.</li>
* </ul>
*
- * <p><b>Estimator:</b> each sample casts one cosine-weighted hemisphere ray
- * from the surface. At the hit it evaluates direct light with cached shadow
- * tests (next-event estimation) plus the hit surface's current indirect
- * estimate, so bounce light propagates deeper over sweeps without an
- * explicit recursion limit. Two nested exponential moving averages shape
- * what the user sees: the inner per-sample EMA smooths Monte Carlo noise in
- * the indirect term, and the outer per-composite-update EMA wraps the
- * complete sum ambient+direct+indirect with a fixed alpha — lightmaps start
- * at a uniform medium irradiance and glide to the traced solution (lit
- * areas brighten, unlit areas sink to darkness), so no black-to-lit flash
- * or shadow pop is possible. Scene or light changes rebuild the
- * snapshot and restart convergence. When converged, workers idle at a low
- * cadence instead of burning CPU.</p>
+ * <p><b>Estimator:</b> each bounce sample casts one cosine-weighted
+ * hemisphere ray from the surface. At the hit it evaluates direct light
+ * with cached shadow tests (next-event estimation) plus the hit
+ * surface's current indirect estimate, so bounce light propagates deeper
+ * over sweeps without an explicit recursion limit. In phase C two nested
+ * exponential moving averages shape what the user sees: the inner
+ * per-sample EMA smooths Monte Carlo noise in the indirect term, and the
+ * outer per-composite-update EMA wraps the complete sum
+ * ambient+direct+indirect with a fixed alpha, so refinement glides —
+ * no black-to-lit flash or shadow pop after the deliberate phase-A
+ * stamp. When converged, workers idle at a low cadence instead of
+ * burning CPU.</p>
*
- * <p><b>Render-side cost:</b> zero ray casting. Lightmapped triangles paint
- * from their current composite texture; the flat-shading path only reads
- * cached per-polygon values. Frame rate is unaffected by GI quality.</p>
+ * <p><b>Render-side cost:</b> zero ray casting. Lightmapped triangles
+ * paint from their current composite texture; the flat-shading path only
+ * reads cached per-polygon values. Frame rate is unaffected by GI
+ * quality.</p>
*
* <p><b>Limitations:</b> diffuse light only; polygon vertices are used in
- * composite-local space, so scenes combining composites with non-identity
- * transforms are traced incorrectly.</p>
+ * composite-local space, so scenes combining composites with
+ * non-identity transforms are traced incorrectly.</p>
*
* <p><b>Usage:</b></p>
* <pre>{@code
/** Albedo for snapshot entries that carry no flat color (textured triangles). */
private static final Color FALLBACK_ALBEDO = new Color(128, 128, 128);
+ /**
+ * Phased scheduler (centroid direct -> centroid bounce -> per-texel,
+ * near-to-far). False restores the legacy flat round-robin over all
+ * work items. {@code -De3d.gi.phases}.
+ */
+ private static final boolean PHASES =
+ Boolean.parseBoolean(System.getProperty("e3d.gi.phases", "true"));
+
+ /**
+ * Phases B/C: how many entries are sampled concurrently, nearest
+ * first; graduates are replaced by the next-nearest entry.
+ * {@code -De3d.gi.activeWindow}.
+ */
+ private static final int ACTIVE_WINDOW =
+ Integer.parseInt(System.getProperty("e3d.gi.activeWindow", "256"));
+
+ /**
+ * Camera translation (world units) that triggers a re-sort of the
+ * not-yet-activated work queue. {@code -De3d.gi.resortDistance}.
+ */
+ private static final double RESORT_DISTANCE =
+ Double.parseDouble(System.getProperty("e3d.gi.resortDistance", "25"));
+
+ /** Consecutive calm visits/updates that graduate an entry out of the active window. */
+ private static final int GRADUATE_CALM = 3;
+
+ /**
+ * Phase C display freeze: after sampling graduation, a lightmap keeps
+ * recompositing (rays stopped — cheap) until its per-texel estimate
+ * movement falls below this many light units. Graduation can freeze
+ * the outer EMA several light units short of the target (calm at 1.0
+ * with alpha 0.2 = residual distance < 5), which survives as visible
+ * per-triangle seams against neighboring stamps; the tail erodes it
+ * asymptotically. {@code -De3d.gi.freezeThreshold}.
+ */
+ private static final double FREEZE_THRESHOLD =
+ Double.parseDouble(System.getProperty("e3d.gi.freezeThreshold", "0.1"));
+
/**
* EMA policy for the inner per-sample indirect blend: "fixed" (default,
* 0.15) keeps every ray hit equally intensive forever — fading toward
Double.parseDouble(System.getProperty("e3d.gi.compositeAlpha", "0.2"));
/**
- * Convergence: declared after five consecutive composite updates whose
- * average per-texel estimate movement falls below this many light
- * units. With a constant alpha the estimate never freezes completely
- * (Monte Carlo jitter), so this judges the VISIBLE movement, not the
- * per-sample deltas. {@code -De3d.gi.calmThreshold}.
+ * Convergence: legacy mode declares it after five consecutive composite
+ * updates whose average per-texel estimate movement falls below this
+ * many light units; phased mode uses it as the per-entry calm
+ * threshold for graduation. With a constant alpha the estimate never
+ * freezes completely (Monte Carlo jitter), so this judges the VISIBLE
+ * movement, not the per-sample deltas. {@code -De3d.gi.calmThreshold}.
*/
private static final double CALM_THRESHOLD =
Double.parseDouble(System.getProperty("e3d.gi.calmThreshold", "1.0"));
+ /** Progressive phases, strict global order. */
+ private enum Phase {
+ /** Direct light at polygon centroids, near-to-far, one visit each. */
+ CENTROID_DIRECT,
+ /** Multi-bounce indirect at polygon centroids, near-to-far windowed. */
+ CENTROID_BOUNCE,
+ /** Per-texel refinement of lightmapped triangles, near-to-far windowed. */
+ TEXEL,
+ /** Everything graduated; workers idle. */
+ DONE
+ }
+
private final ShapeCollection shapes;
private final LightingManager lightingManager;
private final int threadCount;
+ /** Camera position source for distance ordering; null disables sorting/re-sorting. */
+ private final Supplier<Point3D> cameraPosition;
private final List<Thread> threads = new ArrayList<>();
private volatile boolean running;
private final AtomicInteger workIndex = new AtomicInteger();
private volatile int calmSweeps;
private volatile long lastCompositeUpdate;
+
+ /** Last DEBUG heartbeat timestamp (any worker). */
+ private volatile long lastHeartbeat;
private final java.util.concurrent.atomic.AtomicBoolean compositeUpdateInFlight =
new java.util.concurrent.atomic.AtomicBoolean();
+ // --- Phased scheduler state (guarded by queueLock except where noted) ---
+
+ /** Current phase; volatile, workers read it every iteration. */
+ private volatile Phase phase = Phase.CENTROID_DIRECT;
+ private final Object queueLock = new Object();
+ /** Work queue of the current phase, sorted near-to-far. */
+ private TriangleBvh.Entry[] phaseQueue = new TriangleBvh.Entry[0];
+ /** Phase A: next entry to compute. Phases B/C: next entry to activate. */
+ private int queueCursor;
+ /** Phase A visits currently being processed by a worker. */
+ private final AtomicInteger inFlight = new AtomicInteger();
+ /** Active window entries (phases B/C). */
+ private final List<TriangleBvh.Entry> activeList = new ArrayList<>();
+ /** Published snapshot of the active window for lock-free round-robin. */
+ private volatile TriangleBvh.Entry[] active = new TriangleBvh.Entry[0];
+ /** Round-robin cursor over {@link #active}. */
+ private final AtomicInteger activeCursor = new AtomicInteger();
+ /** Camera position at snapshot build / last re-sort. */
+ private double lastCameraX = Double.NaN, lastCameraY, lastCameraZ;
+
private static class Snapshot {
List<TriangleBvh.Entry> entries;
TriangleBvh bvh;
List<LightSource> lights;
IdentityHashMap<LightSource, Integer> lightIndex;
double ambientR, ambientG, ambientB;
- /** Flattened work list: one item per lightmap texel / plain polygon. */
+ /** Flattened work list (legacy mode): one item per lightmap texel / plain polygon. */
WorkItem[] workItems;
/** All lightmaps in the snapshot (for composite updates). */
List<Lightmap> lightmaps;
+ /** Phased mode: all entries sorted near-to-far from the camera. */
+ TriangleBvh.Entry[] sortedByDistance;
+ /** Phased mode: lightmapped entries only, sorted near-to-far. */
+ TriangleBvh.Entry[] lightmappedSorted;
+ /** Phased mode: lightmap back to its snapshot entry (phase C graduation). */
+ IdentityHashMap<Lightmap, TriangleBvh.Entry> lightmapEntry;
+ /** Phase C lightmaps that graduated sampling but have not display-frozen yet. */
+ int tailCount;
}
- /** One unit of GI work: a lightmap texel, or a whole plain polygon. */
+ /** One unit of legacy GI work: a lightmap texel, or a whole plain polygon. */
private static class WorkItem {
TriangleBvh.Entry entry;
int texel; // -1 = plain polygon
/**
* Creates the GI system. Call {@link #start()} to begin tracing.
+ * Distance ordering is disabled (uniform order, no camera re-sort).
*
* @param shapes the scene to trace
* @param lightingManager the lights to sample
public GlobalIllumination(final ShapeCollection shapes,
final LightingManager lightingManager,
final int threadCount) {
+ this(shapes, lightingManager, threadCount, null);
+ }
+
+ /**
+ * Creates the GI system. Call {@link #start()} to begin tracing.
+ *
+ * @param shapes the scene to trace
+ * @param lightingManager the lights to sample
+ * @param threadCount dedicated worker threads (2 is a good default)
+ * @param cameraPosition supplies the camera position for near-to-far
+ * ordering and re-sort triggers; null disables
+ * distance ordering (uniform order)
+ */
+ public GlobalIllumination(final ShapeCollection shapes,
+ final LightingManager lightingManager,
+ final int threadCount,
+ final Supplier<Point3D> cameraPosition) {
this.shapes = shapes;
this.lightingManager = lightingManager;
this.threadCount = Math.max(1, threadCount);
+ this.cameraPosition = cameraPosition;
}
/** Registers the GI provider and starts the worker threads. */
/**
* Returns whether the solution has converged (workers idling at a low
- * duty cycle). Convergence is declared after five consecutive composite
- * updates whose average per-texel estimate movement is below
- * {@code e3d.gi.calmThreshold} (default 1.0 light unit).
+ * duty cycle). Legacy mode: five consecutive composite updates below
+ * {@code e3d.gi.calmThreshold}. Phased mode: all phases drained
+ * (every entry graduated out of the active window).
*
* @return {@code true} when converged
*/
public boolean isConverged() {
- return calmSweeps >= 5;
+ return PHASES ? phase == Phase.DONE : calmSweeps >= 5;
}
/**
- * Returns the number of work items in the current scene snapshot
- * (one per lightmap texel plus one per plain polygon), or 0 when no
- * snapshot has been built yet.
+ * Returns the number of schedulable work units in the current scene
+ * snapshot: phased mode counts polygons, legacy mode counts one item
+ * per lightmap texel plus one per plain polygon. 0 when no snapshot
+ * has been built yet.
*
- * @return the work item count
+ * @return the work unit count
*/
public int getWorkItemCount() {
final Snapshot snap = snapshot;
- return snap == null || snap.workItems == null ? 0 : snap.workItems.length;
+ if (snap == null)
+ return 0;
+ if (PHASES)
+ return snap.sortedByDistance == null ? 0 : snap.sortedByDistance.length;
+ return snap.workItems == null ? 0 : snap.workItems.length;
}
// ------------------------------------------------------------------
try {
maybeRebuildSnapshot();
final Snapshot snap = snapshot;
- if (snap == null || snap.workItems.length == 0) {
+ if (snap == null || snap.entries.isEmpty()) {
Thread.sleep(100);
continue;
}
- // One sweep over all work items. Convergence is judged by
- // composite-estimate movement inside updateComposites()
- // (per-sample deltas are Monte Carlo noise and, with the
- // fixed alpha, never settle).
final long sweepStart = System.currentTimeMillis();
- final int size = snap.workItems.length;
- double deltaSum = 0;
- for (int i = 0; i < size && running; i++) {
- final int index = Math.floorMod(workIndex.getAndIncrement(), size);
- deltaSum += sample(snap, snap.workItems[index], hit, pos);
- }
- final double avgDelta = deltaSum / size;
+ if (PHASES)
+ phasedStep(snap, hit, pos);
+ else
+ legacySweep(snap, hit, pos);
// Regenerate composite textures at most every
// COMPOSITE_INTERVAL_MS; a paint pass is one texture swap
- // per triangle, invisible to the render threads. Also
- // advances the convergence counter.
+ // per triangle, invisible to the render threads.
final long now = System.currentTimeMillis();
if (now - lastCompositeUpdate >= COMPOSITE_INTERVAL_MS) {
updateComposites(snap);
lastCompositeUpdate = now;
}
- if (DEBUG)
- System.out.println("[GI] sweep done, avgDelta=" + String.format("%.2f", avgDelta)
- + ", calmSweeps=" + calmSweeps);
+ // Heartbeat: where the scheduler sits; a stall (phase not
+ // DONE forever) shows up as an unchanging line.
+ if (DEBUG && PHASES && now - lastHeartbeat >= 10000) {
+ lastHeartbeat = now;
+ synchronized (queueLock) {
+ System.out.println("[GI] heartbeat: phase=" + phase
+ + " active=" + activeList.size()
+ + " queue=" + queueCursor + "/" + phaseQueue.length
+ + " tail=" + snap.tailCount);
+ }
+ }
if (isConverged()) {
- // Converged: cap duty cycle at ~50% of sweep time
- // (a big scene's sweep takes seconds; a flat 250ms
- // sleep would barely throttle it). Hard cap keeps
- // post-edit re-convergence prompt.
- final long sweepMillis = System.currentTimeMillis() - sweepStart;
- Thread.sleep(Math.min(IDLE_SLEEP_MAX_MS,
- Math.max(IDLE_SLEEP_MS, sweepMillis)));
+ // Converged: cap duty cycle. Legacy mode proportions
+ // the sleep to the sweep; phased DONE has no sweep
+ // concept and sleeps at the minimum cadence (a scene
+ // edit rebuilds the snapshot and restarts phase A).
+ if (PHASES)
+ Thread.sleep(IDLE_SLEEP_MS);
+ else {
+ final long sweepMillis = System.currentTimeMillis() - sweepStart;
+ Thread.sleep(Math.min(IDLE_SLEEP_MAX_MS,
+ Math.max(IDLE_SLEEP_MS, sweepMillis)));
+ }
}
} catch (final InterruptedException e) {
return;
}
}
- /** One progressive sample: one shadow ray + one bounce ray. */
+ /** Legacy scheduler: one round-robin sweep over all work items. */
+ private void legacySweep(final Snapshot snap, final TriangleBvh.Hit hit, final double[] pos) {
+ if (snap.workItems.length == 0)
+ return;
+ // Convergence is judged by composite-estimate movement inside
+ // updateComposites() (per-sample deltas are Monte Carlo noise and,
+ // with the fixed alpha, never settle).
+ final int size = snap.workItems.length;
+ double deltaSum = 0;
+ for (int i = 0; i < size && running; i++) {
+ final int index = Math.floorMod(workIndex.getAndIncrement(), size);
+ deltaSum += sample(snap, snap.workItems[index], hit, pos);
+ }
+ if (DEBUG)
+ System.out.println("[GI] sweep done, avgDelta=" + String.format("%.2f", deltaSum / size)
+ + ", calmSweeps=" + calmSweeps);
+ }
+
+ // ------------------------------------------------------------------
+ // Phased scheduler
+ // ------------------------------------------------------------------
+
+ /** One phased work step: a phase-A one-shot, or one sample in the active window. */
+ private void phasedStep(final Snapshot snap, final TriangleBvh.Hit hit,
+ final double[] pos) throws InterruptedException {
+ maybeResort(snap);
+ switch (phase) {
+ case CENTROID_DIRECT: {
+ final TriangleBvh.Entry entry = grabPhaseA();
+ if (entry == null) {
+ advancePhaseIfDrained(snap);
+ Thread.sleep(10);
+ return;
+ }
+ try {
+ sampleCentroidDirect(snap, entry);
+ } finally {
+ inFlight.decrementAndGet();
+ }
+ return;
+ }
+ case CENTROID_BOUNCE: {
+ final TriangleBvh.Entry entry = grabActive();
+ if (entry == null) {
+ advancePhaseIfDrained(snap);
+ Thread.sleep(10);
+ return;
+ }
+ if (entry.graduated)
+ return; // raced with graduation: the extra visit is pointless
+ final double delta = sampleCentroidBounce(snap, entry, hit);
+ if (delta < CALM_THRESHOLD) {
+ if (++entry.calmVisits >= GRADUATE_CALM)
+ // The sample took time; a B->C transition may have
+ // happened meanwhile. Graduating now would mark the
+ // entry done without phase C ever seeing it (the
+ // window fill skips graduated entries), and would
+ // leak a freeze-tail slot: guard on the phase.
+ graduate(entry, Phase.CENTROID_BOUNCE);
+ } else {
+ entry.calmVisits = 0;
+ }
+ return;
+ }
+ case TEXEL: {
+ final TriangleBvh.Entry entry = grabActive();
+ if (entry == null) {
+ advancePhaseIfDrained(snap);
+ Thread.sleep(10);
+ return;
+ }
+ if (entry.graduated)
+ return;
+ final Lightmap lightmap = entry.lightmap;
+ final int texel = lightmap.validTexels[
+ Math.floorMod(lightmap.nextTexel++, lightmap.validTexels.length)];
+ sampleTexel(snap, entry, texel, hit, pos);
+ return;
+ }
+ default:
+ // DONE: nothing to sample; the converged sleep is in workLoop.
+ }
+ }
+
+ /** Phase A: takes the next uncomputed entry, or null when the queue is drained. */
+ private TriangleBvh.Entry grabPhaseA() {
+ synchronized (queueLock) {
+ if (queueCursor >= phaseQueue.length)
+ return null;
+ inFlight.incrementAndGet();
+ return phaseQueue[queueCursor++];
+ }
+ }
+
+ /** Phases B/C: round-robin pick from the active window, or null when empty. */
+ private TriangleBvh.Entry grabActive() {
+ final TriangleBvh.Entry[] act = active;
+ if (act.length == 0)
+ return null;
+ return act[Math.floorMod(activeCursor.getAndIncrement(), act.length)];
+ }
+
+ /**
+ * Advances the phase when the current one is drained: phase A on queue
+ * drain with zero in-flight visits, phases B/C when the window is empty
+ * and no entries remain to activate.
+ */
+ private void advancePhaseIfDrained(final Snapshot snap) {
+ synchronized (queueLock) {
+ switch (phase) {
+ case CENTROID_DIRECT:
+ if (queueCursor >= phaseQueue.length && inFlight.get() == 0) {
+ phase = Phase.CENTROID_BOUNCE;
+ phaseQueue = snap.sortedByDistance;
+ queueCursor = 0;
+ fillActiveWindowLocked();
+ if (DEBUG)
+ System.out.println("[GI] phase A drained, entering centroid bounce");
+ }
+ break;
+ case CENTROID_BOUNCE:
+ if (activeList.isEmpty() && queueCursor >= phaseQueue.length) {
+ phase = Phase.TEXEL;
+ phaseQueue = snap.lightmappedSorted;
+ queueCursor = 0;
+ // Phase B graduated every entry; phase C reuses the
+ // same Entry objects, so re-arm graduation for the
+ // per-texel convergence judgement.
+ for (final TriangleBvh.Entry entry : phaseQueue) {
+ entry.graduated = false;
+ entry.calmVisits = 0;
+ }
+ fillActiveWindowLocked();
+ if (DEBUG)
+ System.out.println("[GI] phase B converged, entering per-texel refinement ("
+ + phaseQueue.length + " lightmaps)");
+ }
+ break;
+ case TEXEL:
+ if (activeList.isEmpty() && queueCursor >= phaseQueue.length
+ && snap.tailCount == 0) {
+ phase = Phase.DONE;
+ if (DEBUG)
+ System.out.println("[GI] phase C converged, GI idling");
+ }
+ break;
+ default:
+ }
+ }
+ }
+
+ /**
+ * Moves an entry out of the active window and backfills from the queue.
+ * The graduation only applies when the engine is still in {@code expected}
+ * phase: a sample that started before a phase transition must not
+ * graduate the entry in the new phase (it would be skipped there
+ * forever, and in phase C would leak a freeze-tail slot).
+ */
+ private void graduate(final TriangleBvh.Entry entry, final Phase expected) {
+ synchronized (queueLock) {
+ if (phase != expected || entry.graduated)
+ return;
+ entry.graduated = true;
+ activeList.remove(entry);
+ final Snapshot snap = snapshot;
+ if (phase == Phase.TEXEL && snap != null)
+ snap.tailCount++; // enters the display-freeze tail
+ fillActiveWindowLocked();
+ }
+ }
+
+ /** Fills the active window from the queue up to {@link #ACTIVE_WINDOW}. */
+ private void fillActiveWindowLocked() {
+ while (activeList.size() < ACTIVE_WINDOW && queueCursor < phaseQueue.length) {
+ final TriangleBvh.Entry entry = phaseQueue[queueCursor++];
+ if (entry.graduated)
+ continue;
+ if (phase == Phase.TEXEL)
+ entry.lightmap.texelPhase = true;
+ activeList.add(entry);
+ }
+ active = activeList.toArray(new TriangleBvh.Entry[0]);
+ }
+
+ /**
+ * Re-sorts the not-yet-activated queue tail when the camera has moved
+ * more than {@link #RESORT_DISTANCE} since the snapshot build or the
+ * last re-sort. Active and graduated entries keep their state.
+ */
+ private void maybeResort(final Snapshot snap) {
+ if (cameraPosition == null)
+ return;
+ final Point3D cam = cameraPosition.get();
+ if (cam == null)
+ return;
+ if (Double.isNaN(lastCameraX)) {
+ lastCameraX = cam.x;
+ lastCameraY = cam.y;
+ lastCameraZ = cam.z;
+ return;
+ }
+ final double dx = cam.x - lastCameraX;
+ final double dy = cam.y - lastCameraY;
+ final double dz = cam.z - lastCameraZ;
+ if (dx * dx + dy * dy + dz * dz <= RESORT_DISTANCE * RESORT_DISTANCE)
+ return;
+ synchronized (queueLock) {
+ // Re-check under the lock: another worker may have re-sorted already.
+ final double dx2 = cam.x - lastCameraX;
+ final double dy2 = cam.y - lastCameraY;
+ final double dz2 = cam.z - lastCameraZ;
+ if (dx2 * dx2 + dy2 * dy2 + dz2 * dz2 <= RESORT_DISTANCE * RESORT_DISTANCE)
+ return;
+ lastCameraX = cam.x;
+ lastCameraY = cam.y;
+ lastCameraZ = cam.z;
+ final int from = queueCursor;
+ if (from >= phaseQueue.length)
+ return;
+ for (int i = from; i < phaseQueue.length; i++)
+ phaseQueue[i].distance = distanceSquared(phaseQueue[i], cam);
+ Arrays.sort(phaseQueue, from, phaseQueue.length,
+ Comparator.comparingDouble(e -> e.distance));
+ if (DEBUG)
+ System.out.println("[GI] re-sorted " + (phaseQueue.length - from)
+ + " pending entries (camera moved)");
+ }
+ }
+
+ private static float distanceSquared(final TriangleBvh.Entry entry, final Point3D cam) {
+ final double dx = entry.centroidX - cam.x;
+ final double dy = entry.centroidY - cam.y;
+ final double dz = entry.centroidZ - cam.z;
+ return (float) (dx * dx + dy * dy + dz * dz);
+ }
+
+ // ------------------------------------------------------------------
+ // Sampling
+ // ------------------------------------------------------------------
+
+ /** One legacy progressive sample: one shadow ray + one bounce ray. */
private double sample(final Snapshot snap, final WorkItem item,
final TriangleBvh.Hit hit, final double[] pos) {
- final TriangleBvh.Entry entry = item.entry;
- final Lightmap lightmap = entry.lightmap;
+ if (item.entry.lightmap != null)
+ return sampleTexel(snap, item.entry, item.texel, hit, pos);
+ return samplePlain(snap, item.entry, true, hit);
+ }
- final double ox, oy, oz, nx, ny, nz;
- if (lightmap != null) {
- lightmap.texelWorldPosition(item.texel, pos);
- nx = lightmap.normalX;
- ny = lightmap.normalY;
- nz = lightmap.normalZ;
- ox = pos[0] + nx * ORIGIN_EPSILON;
- oy = pos[1] + ny * ORIGIN_EPSILON;
- oz = pos[2] + nz * ORIGIN_EPSILON;
- } else {
- nx = entry.normal[0];
- ny = entry.normal[1];
- nz = entry.normal[2];
- ox = entry.centroidX + nx * ORIGIN_EPSILON;
- oy = entry.centroidY + ny * ORIGIN_EPSILON;
- oz = entry.centroidZ + nz * ORIGIN_EPSILON;
- }
+ /**
+ * Samples one lightmap texel: shadow ray(s) plus one bounce ray and
+ * the per-texel indirect EMA update. Used by the legacy scheduler and
+ * by phased mode's phase C.
+ *
+ * @return the EMA-weighted estimate delta (convergence signal)
+ */
+ private double sampleTexel(final Snapshot snap, final TriangleBvh.Entry entry,
+ final int texel, final TriangleBvh.Hit hit, final double[] pos) {
+ final Lightmap lightmap = entry.lightmap;
+ lightmap.texelWorldPosition(texel, pos);
+ final double nx = lightmap.normalX;
+ final double ny = lightmap.normalY;
+ final double nz = lightmap.normalZ;
+ final double ox = pos[0] + nx * ORIGIN_EPSILON;
+ final double oy = pos[1] + ny * ORIGIN_EPSILON;
+ final double oz = pos[2] + nz * ORIGIN_EPSILON;
// 1. Shadow rays. First visit per texel: test ALL lights, so direct
// light + hard shadows appear after one sweep instead of trickling
// in over lightCount sweeps. Afterwards: one light, round-robin.
final int lightCount = snap.lights.size();
+ final boolean firstVisit = lightmap.sampleCounts[texel] == 0;
+ if (firstVisit)
+ lightmap.texelsSampled++; // graduation requires full coverage
if (lightCount > 0) {
- if (lightmap != null) {
- lightmap.ensureLightCapacity(lightCount);
- final boolean firstVisit = lightmap.sampleCounts[item.texel] == 0;
- if (firstVisit) {
- for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++)
- lightmap.lightVisibility[item.texel * lightCount + i] =
- shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(i))
- ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
- } else {
- final int lightIdx = lightmap.nextLight++ % lightCount;
- lightmap.lightVisibility[item.texel * lightCount + lightIdx] =
- shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx))
+ lightmap.ensureLightCapacity(lightCount);
+ if (firstVisit) {
+ for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++)
+ lightmap.lightVisibility[texel * lightCount + i] =
+ shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(i))
? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
- }
} else {
- final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
- final int lightIdx = state.nextLight++ % lightCount;
- final boolean visible = shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx));
- final long bit = 1L << lightIdx;
- synchronized (state) {
- state.visibleBits = visible ? (state.visibleBits | bit) : (state.visibleBits & ~bit);
- state.knownBits |= bit;
- }
+ final int lightIdx = lightmap.nextLight++ % lightCount;
+ lightmap.lightVisibility[texel * lightCount + lightIdx] =
+ shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx))
+ ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
}
}
- // 2. Bounce ray: cosine-weighted hemisphere around the normal.
- final double[] dir = cosineHemisphere(nx, ny, nz, ThreadLocalRandom.current());
+ // 2. Bounce ray + per-texel EMA update.
+ final double[] target = bounceTarget(snap, nx, ny, nz, ox, oy, oz, hit);
+
+ final int count = Math.min(32000, ++lightmap.sampleCounts[texel]);
+ final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
+ : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
+ final float dR = (float) (target[0] - lightmap.indirectR[texel]);
+ final float dG = (float) (target[1] - lightmap.indirectG[texel]);
+ final float dB = (float) (target[2] - lightmap.indirectB[texel]);
+ lightmap.indirectR[texel] += alpha * dR;
+ lightmap.indirectG[texel] += alpha * dG;
+ lightmap.indirectB[texel] += alpha * dB;
+ return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha;
+ }
- double targetR = 0, targetG = 0, targetB = 0;
- if (snap.bvh.nearest(ox, oy, oz, dir[0], dir[1], dir[2], hit)) {
- // Direct irradiance at the hit point (clamped to display range)
- // plus the hit surface's current indirect estimate.
- final double[] irr = directIrradiance(snap, hit);
- final Color hitColor = colorOf(hit.entry);
- final float hiR, hiG, hiB;
- if (hit.entry.lightmap != null) {
- final int hitTexel = hit.entry.lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ);
- hiR = hit.entry.lightmap.indirectR[hitTexel];
- hiG = hit.entry.lightmap.indirectG[hitTexel];
- hiB = hit.entry.lightmap.indirectB[hitTexel];
- } else {
- final GiState hitState = states.get(hit.entry.polygon);
- hiR = hitState == null ? 0 : hitState.indirectR;
- hiG = hitState == null ? 0 : hitState.indirectG;
- hiB = hitState == null ? 0 : hitState.indirectB;
+ /**
+ * Per-polygon sample for plain solid polygons: one bounce ray and the
+ * indirect EMA update, plus shadow rays unless they were settled in
+ * phase A.
+ *
+ * @param withShadowRays true in legacy mode (one light, round-robin);
+ * false in phased mode's phase B
+ * @return the EMA-weighted estimate delta (convergence signal)
+ */
+ private double samplePlain(final Snapshot snap, final TriangleBvh.Entry entry,
+ final boolean withShadowRays, final TriangleBvh.Hit hit) {
+ final double nx = entry.normal[0];
+ final double ny = entry.normal[1];
+ final double nz = entry.normal[2];
+ final double ox = entry.centroidX + nx * ORIGIN_EPSILON;
+ final double oy = entry.centroidY + ny * ORIGIN_EPSILON;
+ final double oz = entry.centroidZ + nz * ORIGIN_EPSILON;
+
+ final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
+ final int lightCount = snap.lights.size();
+ if (withShadowRays && lightCount > 0) {
+ final int lightIdx = state.nextLight++ % lightCount;
+ final boolean visible = shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx));
+ final long bit = 1L << lightIdx;
+ synchronized (state) {
+ state.visibleBits = visible ? (state.visibleBits | bit) : (state.visibleBits & ~bit);
+ state.knownBits |= bit;
}
- targetR = BOUNCE_GAIN * hitColor.r * (irr[0] + hiR) / 255.0;
- targetG = BOUNCE_GAIN * hitColor.g * (irr[1] + hiG) / 255.0;
- targetB = BOUNCE_GAIN * hitColor.b * (irr[2] + hiB) / 255.0;
}
- // Inner EMA update. "fixed" mode (default): every ray hit lands
- // with the same weight forever, so unlit areas keep fading to
- // darkness at the same rate lit areas brighten. "adaptive" mode:
- // alpha starts at ~1 and decays with sample count (floored).
- if (lightmap != null) {
- final int count = Math.min(32000, ++lightmap.sampleCounts[item.texel]);
+ final double[] target = bounceTarget(snap, nx, ny, nz, ox, oy, oz, hit);
+ synchronized (state) {
+ final int count = Math.min(32000, ++state.samples);
final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
: (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
- final float dR = (float) (targetR - lightmap.indirectR[item.texel]);
- final float dG = (float) (targetG - lightmap.indirectG[item.texel]);
- final float dB = (float) (targetB - lightmap.indirectB[item.texel]);
- lightmap.indirectR[item.texel] += alpha * dR;
- lightmap.indirectG[item.texel] += alpha * dG;
- lightmap.indirectB[item.texel] += alpha * dB;
+ final float dR = (float) (target[0] - state.indirectR);
+ final float dG = (float) (target[1] - state.indirectG);
+ final float dB = (float) (target[2] - state.indirectB);
+ state.indirectR += alpha * dR;
+ state.indirectG += alpha * dG;
+ state.indirectB += alpha * dB;
return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha;
+ }
+ }
+
+ /**
+ * Phase A: computes ambient + direct irradiance at the polygon
+ * centroid with shadow rays to ALL lights, then stamps the result
+ * onto the whole polygon at once. Lightmapped triangles flip their
+ * composite texture to the flat centroid value (the visible wave);
+ * plain polygons publish per-light visibility for the flat-shading
+ * path.
+ */
+ private void sampleCentroidDirect(final Snapshot snap, final TriangleBvh.Entry entry) {
+ final double nx = entry.normal[0];
+ final double ny = entry.normal[1];
+ final double nz = entry.normal[2];
+ final double ox = entry.centroidX + nx * ORIGIN_EPSILON;
+ final double oy = entry.centroidY + ny * ORIGIN_EPSILON;
+ final double oz = entry.centroidZ + nz * ORIGIN_EPSILON;
+
+ final int lightCount = snap.lights.size();
+ final int tracked = Math.min(lightCount, MAX_TRACKED_LIGHTS);
+ final boolean[] visible = new boolean[tracked];
+
+ double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB;
+ for (int i = 0; i < tracked; i++) {
+ final LightSource light = snap.lights.get(i);
+ visible[i] = shadowTest(snap, ox, oy, oz, nx, ny, nz, light);
+ if (!visible[i])
+ continue;
+ final Point3D lightPos = light.getPosition();
+ final double dx = lightPos.x - ox;
+ final double dy = lightPos.y - oy;
+ final double dz = lightPos.z - oz;
+ final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
+ if (dist < 0.0001)
+ continue;
+ final double dot = (nx * dx + ny * dy + nz * dz) / dist;
+ if (dot <= 0)
+ continue;
+ final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist);
+ final double intensity = dot * attenuation * light.getIntensity();
+ final Color lightColor = light.getColor();
+ r += lightColor.r * intensity;
+ g += lightColor.g * intensity;
+ b += lightColor.b * intensity;
+ }
+
+ final Lightmap lightmap = entry.lightmap;
+ if (lightmap != null) {
+ lightmap.ensureLightCapacity(lightCount);
+ if (tracked > 0) {
+ final byte[] centroidVisibility = new byte[lightCount];
+ for (int i = 0; i < tracked; i++)
+ centroidVisibility[i] = visible[i]
+ ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
+ lightmap.seedCentroidVisibility(centroidVisibility);
+ }
+ lightmap.centroidR = (float) Math.min(255, r);
+ lightmap.centroidG = (float) Math.min(255, g);
+ lightmap.centroidB = (float) Math.min(255, b);
+ lightmap.seedDisplay(lightmap.centroidR, lightmap.centroidG, lightmap.centroidB);
+ lightmap.centroidReady = true;
} else {
final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
synchronized (state) {
- final int count = Math.min(32000, ++state.samples);
- final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
- : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
- final float dR = (float) (targetR - state.indirectR);
- final float dG = (float) (targetG - state.indirectG);
- final float dB = (float) (targetB - state.indirectB);
- state.indirectR += alpha * dR;
- state.indirectG += alpha * dG;
- state.indirectB += alpha * dB;
- return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha;
+ for (int i = 0; i < tracked; i++) {
+ final long bit = 1L << i;
+ state.visibleBits = visible[i]
+ ? (state.visibleBits | bit) : (state.visibleBits & ~bit);
+ state.knownBits |= bit;
+ }
}
}
}
+ /**
+ * Phase B: one bounce ray from the polygon centroid, blended into the
+ * centroid indirect estimate. Cheap enough to run scene-wide before
+ * any per-texel work starts.
+ *
+ * @return the normalized graduation signal: the EMA-weighted centroid
+ * indirect delta scaled so that "calm" means below 1 light unit
+ * or below 2% of the tracked magnitude (bright entries must be
+ * able to graduate)
+ */
+ private double sampleCentroidBounce(final Snapshot snap, final TriangleBvh.Entry entry,
+ final TriangleBvh.Hit hit) {
+ final double nx = entry.normal[0];
+ final double ny = entry.normal[1];
+ final double nz = entry.normal[2];
+ final double ox = entry.centroidX + nx * ORIGIN_EPSILON;
+ final double oy = entry.centroidY + ny * ORIGIN_EPSILON;
+ final double oz = entry.centroidZ + nz * ORIGIN_EPSILON;
+
+ final double[] target = bounceTarget(snap, nx, ny, nz, ox, oy, oz, hit);
+
+ final int count = Math.min(32000, ++entry.centroidSamples);
+ final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
+ : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
+ final Lightmap lightmap = entry.lightmap;
+ if (lightmap != null) {
+ final float dR = (float) (target[0] - lightmap.centroidIndirectR);
+ final float dG = (float) (target[1] - lightmap.centroidIndirectG);
+ final float dB = (float) (target[2] - lightmap.centroidIndirectB);
+ lightmap.centroidIndirectR += alpha * dR;
+ lightmap.centroidIndirectG += alpha * dG;
+ lightmap.centroidIndirectB += alpha * dB;
+ return graduationSignal(
+ Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha,
+ (lightmap.centroidIndirectR + lightmap.centroidIndirectG
+ + lightmap.centroidIndirectB) / 3.0);
+ }
+ final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
+ synchronized (state) {
+ final float dR = (float) (target[0] - state.indirectR);
+ final float dG = (float) (target[1] - state.indirectG);
+ final float dB = (float) (target[2] - state.indirectB);
+ state.indirectR += alpha * dR;
+ state.indirectG += alpha * dG;
+ state.indirectB += alpha * dB;
+ return graduationSignal(
+ Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha,
+ (state.indirectR + state.indirectG + state.indirectB) / 3.0);
+ }
+ }
+
+ /**
+ * Normalizes a graduation delta against the magnitude of the value being
+ * tracked: Monte Carlo jitter scales with brightness, so an absolute
+ * threshold can never be reached by a bright entry (it would squat in
+ * the active window forever and starve everything behind it). Calm when
+ * the raw delta is below 1.0 light unit or below 2% of the magnitude.
+ */
+ private static double graduationSignal(final double rawDelta, final double magnitude) {
+ return rawDelta / Math.max(1.0, 0.02 * magnitude);
+ }
+
+ /**
+ * One cosine-weighted bounce ray from the given surface point.
+ *
+ * @return bounce target radiance contribution {r, g, b}, zeros on a miss
+ */
+ private double[] bounceTarget(final Snapshot snap,
+ final double nx, final double ny, final double nz,
+ final double ox, final double oy, final double oz,
+ final TriangleBvh.Hit hit) {
+ final double[] dir = cosineHemisphere(nx, ny, nz, ThreadLocalRandom.current());
+ if (!snap.bvh.nearest(ox, oy, oz, dir[0], dir[1], dir[2], hit))
+ return new double[3];
+
+ // Direct irradiance at the hit point (clamped to display range)
+ // plus the hit surface's current indirect estimate.
+ final double[] irr = directIrradiance(snap, hit);
+ final Color hitColor = colorOf(hit.entry);
+ final float hiR, hiG, hiB;
+ if (hit.entry.lightmap != null) {
+ final int hitTexel = hit.entry.lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ);
+ hiR = hit.entry.lightmap.indirectR[hitTexel];
+ hiG = hit.entry.lightmap.indirectG[hitTexel];
+ hiB = hit.entry.lightmap.indirectB[hitTexel];
+ } else {
+ final GiState hitState = states.get(hit.entry.polygon);
+ hiR = hitState == null ? 0 : hitState.indirectR;
+ hiG = hitState == null ? 0 : hitState.indirectG;
+ hiB = hitState == null ? 0 : hitState.indirectB;
+ }
+ return new double[]{
+ BOUNCE_GAIN * hitColor.r * (irr[0] + hiR) / 255.0,
+ BOUNCE_GAIN * hitColor.g * (irr[1] + hiG) / 255.0,
+ BOUNCE_GAIN * hitColor.b * (irr[2] + hiB) / 255.0};
+ }
+
/** Shadow ray from a surface point toward a light. */
private boolean shadowTest(final Snapshot snap,
final double ox, final double oy, final double oz,
if (!compositeUpdateInFlight.compareAndSet(false, true))
return;
try {
- final int lightCount = snap.lights.size();
- final double[] pos = new double[3];
- double movementSum = 0;
- long texelTotal = 0;
- for (final Lightmap lightmap : snap.lightmaps) {
- lightmap.ensureLightCapacity(lightCount);
- final int width = lightmap.width;
- final int height = lightmap.height;
- final int texelCount = width * height;
-
- // 1. Total irradiance per valid texel (float, no clamping yet).
- final float[] irrR = new float[texelCount];
- final float[] irrG = new float[texelCount];
- final float[] irrB = new float[texelCount];
- for (final int texel : lightmap.validTexels) {
- lightmap.texelWorldPosition(texel, pos);
- double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB;
- for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) {
- if (lightmap.lightVisibility[texel * lightCount + i] == Lightmap.VISIBILITY_OCCLUDED)
- continue;
- final LightSource light = snap.lights.get(i);
- final Point3D lightPos = light.getPosition();
- final double dx = lightPos.x - pos[0];
- final double dy = lightPos.y - pos[1];
- final double dz = lightPos.z - pos[2];
- final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
- if (dist < 0.0001)
- continue;
- final double dot = (lightmap.normalX * dx + lightmap.normalY * dy
- + lightmap.normalZ * dz) / dist;
- if (dot <= 0)
- continue;
- final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist);
- final double intensity = dot * attenuation * light.getIntensity();
- final Color lightColor = light.getColor();
- r += lightColor.r * intensity;
- g += lightColor.g * intensity;
- b += lightColor.b * intensity;
+ if (PHASES)
+ updateCompositesPhased(snap);
+ else
+ updateCompositesLegacy(snap);
+ } finally {
+ compositeUpdateInFlight.set(false);
+ }
+ }
+
+ /**
+ * Phased composite routing: pre-refinement lightmaps composite
+ * uniformly from their centroid values; lightmaps in the texel phase
+ * get the full per-texel recomputation plus per-lightmap convergence
+ * judgement; graduated (tail) lightmaps keep recompositing without
+ * any further ray sampling until the estimate has fully glided to the
+ * frozen target; frozen lightmaps are skipped entirely.
+ */
+ private void updateCompositesPhased(final Snapshot snap) {
+ final int lightCount = snap.lights.size();
+ final double[] pos = new double[3];
+ for (final Lightmap lightmap : snap.lightmaps) {
+ if (!lightmap.texelPhase) {
+ // Phases A/B: the whole triangle shows its centroid value.
+ if (lightmap.centroidReady)
+ lightmap.compositeFromCentroid();
+ continue;
+ }
+ final TriangleBvh.Entry entry = snap.lightmapEntry.get(lightmap);
+ if (entry != null && entry.frozen)
+ continue; // fully settled: texture already final
+
+ final double movementSum = compositeTexels(snap, lightmap, lightCount, pos);
+ final double avgMovement = movementSum / ((long) lightmap.width * lightmap.height);
+ if (entry == null)
+ continue;
+ if (!entry.graduated) {
+ // Sampling graduation: every texel re-tested at least once
+ // (no centroid-seeded visibility may survive) AND a calm
+ // estimate -> stop casting rays. The calm limit is relative:
+ // Monte Carlo jitter scales with brightness, so a bright
+ // lightmap can never fall below a fixed threshold and would
+ // squat in the active window forever, starving far entries.
+ double estimateSum = 0;
+ for (final int t : lightmap.validTexels)
+ estimateSum += (lightmap.estimateR[t] + lightmap.estimateG[t]
+ + lightmap.estimateB[t]) / 3.0;
+ final double calmLimit = Math.max(CALM_THRESHOLD,
+ 0.02 * estimateSum / lightmap.validTexels.length);
+ final boolean covered =
+ lightmap.texelsSampled >= lightmap.validTexels.length;
+ if (covered && avgMovement < calmLimit) {
+ if (++lightmap.calmComposites >= GRADUATE_CALM) {
+ lightmap.calmComposites = 0; // re-used for the freeze tail
+ graduate(entry, Phase.TEXEL);
}
- // Indirect, lightly blended with valid 4-neighbors:
- // single-texel Monte Carlo spikes are smoothed without
- // blurring real gradients (texels are sub-pixel at 4K).
- final float smoothedR = DESPECKLE ? smoothedIndirect(lightmap.indirectR, lightmap, texel) : lightmap.indirectR[texel];
- final float smoothedG = DESPECKLE ? smoothedIndirect(lightmap.indirectG, lightmap, texel) : lightmap.indirectG[texel];
- final float smoothedB = DESPECKLE ? smoothedIndirect(lightmap.indirectB, lightmap, texel) : lightmap.indirectB[texel];
- irrR[texel] = (float) Math.min(255, r) + smoothedR;
- irrG[texel] = (float) Math.min(255, g) + smoothedG;
- irrB[texel] = (float) Math.min(255, b) + smoothedB;
+ } else {
+ lightmap.calmComposites = 0;
}
-
- // 2. Fill the invalid half (u+v > 1) from nearest valid
- // neighbors, so bilinear upsampling never reads garbage.
- final boolean[] filled = new boolean[texelCount];
- for (final int texel : lightmap.validTexels)
- filled[texel] = true;
- boolean progressed = true;
- while (progressed) {
- progressed = false;
- for (int t = 0; t < texelCount; t++) {
- if (filled[t])
- continue;
- final int i = t % width;
- final int j = t / width;
- final int left = i > 0 ? t - 1 : -1;
- final int right = i < width - 1 ? t + 1 : -1;
- final int up = j > 0 ? t - width : -1;
- final int down = j < height - 1 ? t + width : -1;
- final int source = left >= 0 && filled[left] ? left
- : right >= 0 && filled[right] ? right
- : up >= 0 && filled[up] ? up
- : down >= 0 && filled[down] ? down : -1;
- if (source >= 0) {
- irrR[t] = irrR[source];
- irrG[t] = irrG[source];
- irrB[t] = irrB[source];
- filled[t] = true;
- progressed = true;
+ if (DEBUG)
+ System.out.println("[GI] composite update (texel phase), avgMovement="
+ + String.format("%.2f", avgMovement));
+ } else {
+ // Tail: no new samples, the target is frozen, so movement
+ // decays monotonically (pure EMA glide) until invisible.
+ if (avgMovement < FREEZE_THRESHOLD) {
+ if (++lightmap.calmComposites >= GRADUATE_CALM) {
+ entry.frozen = true;
+ synchronized (queueLock) {
+ snap.tailCount--;
+ if (DEBUG)
+ System.out.println("[GI] frozen, tail left=" + snap.tailCount);
}
}
+ } else {
+ lightmap.calmComposites = 0;
}
+ }
+ }
+ }
- // 3. Blend the computed irradiance into the persistent
- // per-texel estimate (the outer EMA), then write the
- // composite texture 1:1 from the ESTIMATE — the texture
- // can only move COMPOSITE_ALPHA of the remaining
- // distance per update, so direct light, shadows and
- // indirect all fade in/out gradually.
- final Texture back = lightmap.backTexture();
- final int[] pixels = back.primaryBitmap.pixels;
- for (int j = 0; j < height; j++)
- for (int i = 0; i < width; i++) {
- final int t = j * width + i;
- final float dR = (float) (COMPOSITE_ALPHA * (irrR[t] - lightmap.estimateR[t]));
- final float dG = (float) (COMPOSITE_ALPHA * (irrG[t] - lightmap.estimateG[t]));
- final float dB = (float) (COMPOSITE_ALPHA * (irrB[t] - lightmap.estimateB[t]));
- lightmap.estimateR[t] += dR;
- lightmap.estimateG[t] += dG;
- lightmap.estimateB[t] += dB;
- movementSum += Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB)));
- texelTotal++;
- pixels[t] = compositePixel(lightmap,
- lightmap.estimateR[t], lightmap.estimateG[t], lightmap.estimateB[t]);
- }
+ /** Legacy composite path with global convergence judgement. */
+ private void updateCompositesLegacy(final Snapshot snap) {
+ final int lightCount = snap.lights.size();
+ final double[] pos = new double[3];
+ double movementSum = 0;
+ long texelTotal = 0;
+ for (final Lightmap lightmap : snap.lightmaps) {
+ movementSum += compositeTexels(snap, lightmap, lightCount, pos);
+ texelTotal += (long) lightmap.width * lightmap.height;
+ }
+
+ // Convergence: average per-texel movement of the on-screen
+ // estimate. With a constant alpha the estimate never fully
+ // freezes (Monte Carlo jitter), so CALM_THRESHOLD judges the
+ // VISIBLE movement; five calm updates in a row -> idle.
+ final double avgMovement = texelTotal > 0 ? movementSum / texelTotal : 0;
+ if (avgMovement < CALM_THRESHOLD)
+ calmSweeps++;
+ else
+ calmSweeps = 0;
+ if (DEBUG)
+ System.out.println("[GI] composite update, avgMovement="
+ + String.format("%.2f", avgMovement));
+ }
+
+ /**
+ * Regenerates one lightmap's composite texture from the current
+ * per-texel visibility and indirect state, blending into the
+ * persistent per-texel estimate (the outer EMA).
+ *
+ * @return the sum of per-texel estimate movement (convergence signal)
+ */
+ private double compositeTexels(final Snapshot snap, final Lightmap lightmap,
+ final int lightCount, final double[] pos) {
+ lightmap.ensureLightCapacity(lightCount);
+ final int width = lightmap.width;
+ final int height = lightmap.height;
+ final int texelCount = width * height;
+
+ // 1. Total irradiance per valid texel (float, no clamping yet).
+ final float[] irrR = new float[texelCount];
+ final float[] irrG = new float[texelCount];
+ final float[] irrB = new float[texelCount];
+ for (final int texel : lightmap.validTexels) {
+ lightmap.texelWorldPosition(texel, pos);
+ double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB;
+ for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) {
+ if (lightmap.lightVisibility[texel * lightCount + i] == Lightmap.VISIBILITY_OCCLUDED)
+ continue;
+ final LightSource light = snap.lights.get(i);
+ final Point3D lightPos = light.getPosition();
+ final double dx = lightPos.x - pos[0];
+ final double dy = lightPos.y - pos[1];
+ final double dz = lightPos.z - pos[2];
+ final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
+ if (dist < 0.0001)
+ continue;
+ final double dot = (lightmap.normalX * dx + lightmap.normalY * dy
+ + lightmap.normalZ * dz) / dist;
+ if (dot <= 0)
+ continue;
+ final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist);
+ final double intensity = dot * attenuation * light.getIntensity();
+ final Color lightColor = light.getColor();
+ r += lightColor.r * intensity;
+ g += lightColor.g * intensity;
+ b += lightColor.b * intensity;
+ }
+ // Indirect, lightly blended with valid 4-neighbors:
+ // single-texel Monte Carlo spikes are smoothed without
+ // blurring real gradients (texels are sub-pixel at 4K).
+ final float smoothedR = DESPECKLE ? smoothedIndirect(lightmap.indirectR, lightmap, texel) : lightmap.indirectR[texel];
+ final float smoothedG = DESPECKLE ? smoothedIndirect(lightmap.indirectG, lightmap, texel) : lightmap.indirectG[texel];
+ final float smoothedB = DESPECKLE ? smoothedIndirect(lightmap.indirectB, lightmap, texel) : lightmap.indirectB[texel];
+ irrR[texel] = (float) Math.min(255, r) + smoothedR;
+ irrG[texel] = (float) Math.min(255, g) + smoothedG;
+ irrB[texel] = (float) Math.min(255, b) + smoothedB;
+ }
- back.resetResampledBitmapCache();
- if (lightmap.owner != null) {
- lightmap.owner.setTexture(back);
- lightmap.swapBuffers();
+ // 2. Fill the invalid half (u+v > 1) from nearest valid
+ // neighbors, so bilinear upsampling never reads garbage.
+ final boolean[] filled = new boolean[texelCount];
+ for (final int texel : lightmap.validTexels)
+ filled[texel] = true;
+ boolean progressed = true;
+ while (progressed) {
+ progressed = false;
+ for (int t = 0; t < texelCount; t++) {
+ if (filled[t])
+ continue;
+ final int i = t % width;
+ final int j = t / width;
+ final int left = i > 0 ? t - 1 : -1;
+ final int right = i < width - 1 ? t + 1 : -1;
+ final int up = j > 0 ? t - width : -1;
+ final int down = j < height - 1 ? t + width : -1;
+ final int source = left >= 0 && filled[left] ? left
+ : right >= 0 && filled[right] ? right
+ : up >= 0 && filled[up] ? up
+ : down >= 0 && filled[down] ? down : -1;
+ if (source >= 0) {
+ irrR[t] = irrR[source];
+ irrG[t] = irrG[source];
+ irrB[t] = irrB[source];
+ filled[t] = true;
+ progressed = true;
}
+ }
+ }
- // Debug: -De3d.gi.dumpLightmaps=/tmp/lm dumps composites as PNGs.
- if (DUMP_DIR != null)
- dumpLightmap(lightmap, pixels);
+ // 3. Blend the computed irradiance into the persistent
+ // per-texel estimate (the outer EMA), then write the
+ // composite texture 1:1 from the ESTIMATE — the texture
+ // can only move COMPOSITE_ALPHA of the remaining
+ // distance per update, so direct light, shadows and
+ // indirect all fade in/out gradually.
+ double movementSum = 0;
+ final Texture back = lightmap.backTexture();
+ final int[] pixels = back.primaryBitmap.pixels;
+ for (int j = 0; j < height; j++)
+ for (int i = 0; i < width; i++) {
+ final int t = j * width + i;
+ final float dR = (float) (COMPOSITE_ALPHA * (irrR[t] - lightmap.estimateR[t]));
+ final float dG = (float) (COMPOSITE_ALPHA * (irrG[t] - lightmap.estimateG[t]));
+ final float dB = (float) (COMPOSITE_ALPHA * (irrB[t] - lightmap.estimateB[t]));
+ lightmap.estimateR[t] += dR;
+ lightmap.estimateG[t] += dG;
+ lightmap.estimateB[t] += dB;
+ movementSum += Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB)));
+ pixels[t] = compositePixel(lightmap,
+ lightmap.estimateR[t], lightmap.estimateG[t], lightmap.estimateB[t]);
}
- // Convergence: average per-texel movement of the on-screen
- // estimate. With a constant alpha the estimate never fully
- // freezes (Monte Carlo jitter), so CALM_THRESHOLD judges the
- // VISIBLE movement; five calm updates in a row -> idle.
- final double avgMovement = texelTotal > 0 ? movementSum / texelTotal : 0;
- if (avgMovement < CALM_THRESHOLD)
- calmSweeps++;
- else
- calmSweeps = 0;
- if (DEBUG)
- System.out.println("[GI] composite update, avgMovement="
- + String.format("%.2f", avgMovement));
- } finally {
- compositeUpdateInFlight.set(false);
+ back.resetResampledBitmapCache();
+ if (lightmap.owner != null) {
+ lightmap.owner.setTexture(back);
+ lightmap.swapBuffers();
}
+
+ // Debug: -De3d.gi.dumpLightmaps=/tmp/lm dumps composites as PNGs.
+ if (DUMP_DIR != null)
+ dumpLightmap(lightmap, pixels);
+ return movementSum;
}
private static final String DUMP_DIR = System.getProperty("e3d.gi.dumpLightmaps");
final double lightSignature = lightSignature();
if (version == lastSeenRenderListVersion && lightSignature == lastLightSignature)
return;
+ if (DEBUG)
+ System.out.println("[GI] rebuild trigger: renderListVersion "
+ + lastSeenRenderListVersion + " -> " + version
+ + ", lightSignature " + lastLightSignature + " -> " + lightSignature
+ + ", phase was " + phase);
final List<AbstractCoordinateShape> triangles = new ArrayList<>();
shapes.collectRenderTriangles(triangles);
+ // Torn-read guard: a nested composite's cache can rebuild between
+ // the version read above and this collect, yielding a partial
+ // triangle set for a version that will never trigger again (the
+ // bump already happened). Re-read: if it moved, let the next loop
+ // rebuild from a consistent state instead of converging a partial
+ // scene to DONE and idling forever.
+ if (AbstractCompositeShape.getGlobalRenderListVersion() != version)
+ return;
final Snapshot snap = new Snapshot();
snap.entries = new ArrayList<>(triangles.size());
snap.ambientG = ambient.g;
snap.ambientB = ambient.b;
- // Flattened work list: one item per valid lightmap texel,
- // one per plain polygon.
- final List<WorkItem> workItems = new ArrayList<>();
- for (final TriangleBvh.Entry entry : snap.entries) {
- if (entry.lightmap != null) {
- for (final int texel : entry.lightmap.validTexels) {
+ if (PHASES) {
+ // Distance ordering from the camera position at build time.
+ final Point3D cam = cameraPosition == null ? null : cameraPosition.get();
+ for (final TriangleBvh.Entry entry : snap.entries)
+ entry.distance = cam == null ? 0f : distanceSquared(entry, cam);
+ snap.sortedByDistance = snap.entries.stream()
+ .sorted(Comparator.comparingDouble(e -> e.distance))
+ .toArray(TriangleBvh.Entry[]::new);
+ snap.lightmappedSorted = snap.entries.stream()
+ .filter(e -> e.lightmap != null)
+ .sorted(Comparator.comparingDouble(e -> e.distance))
+ .toArray(TriangleBvh.Entry[]::new);
+ snap.lightmapEntry = new IdentityHashMap<>();
+ for (final TriangleBvh.Entry entry : snap.entries)
+ if (entry.lightmap != null) {
+ entry.lightmap.resetPhasedState();
+ snap.lightmapEntry.put(entry.lightmap, entry);
+ }
+ synchronized (queueLock) {
+ phase = Phase.CENTROID_DIRECT;
+ phaseQueue = snap.sortedByDistance;
+ queueCursor = 0;
+ // inFlight must NOT be zeroed here: a worker that grabbed
+ // an entry just before the rebuild decrements it after,
+ // so set(0) would drive the counter negative and the
+ // phase-A drain condition (== 0) would never hold again.
+ // The counter is conserved on its own: pre-rebuild
+ // samples finish and decrement normally.
+ activeList.clear();
+ active = new TriangleBvh.Entry[0];
+ activeCursor.set(0);
+ }
+ if (cam != null) {
+ lastCameraX = cam.x;
+ lastCameraY = cam.y;
+ lastCameraZ = cam.z;
+ } else {
+ lastCameraX = Double.NaN;
+ }
+ } else {
+ // Flattened work list: one item per valid lightmap texel,
+ // one per plain polygon.
+ final List<WorkItem> workItems = new ArrayList<>();
+ for (final TriangleBvh.Entry entry : snap.entries) {
+ if (entry.lightmap != null) {
+ for (final int texel : entry.lightmap.validTexels) {
+ final WorkItem item = new WorkItem();
+ item.entry = entry;
+ item.texel = texel;
+ workItems.add(item);
+ }
+ } else {
final WorkItem item = new WorkItem();
item.entry = entry;
- item.texel = texel;
+ item.texel = -1;
workItems.add(item);
}
- } else {
- final WorkItem item = new WorkItem();
- item.entry = entry;
- item.texel = -1;
- workItems.add(item);
}
+ snap.workItems = workItems.toArray(new WorkItem[0]);
}
- snap.workItems = workItems.toArray(new WorkItem[0]);
snapshot = snap;
states.clear();
lastLightSignature = lightSignature;
calmSweeps = 0; // scene changed: back to full-speed tracing
- if (DEBUG)
- System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, "
- + snap.workItems.length + " work items, "
- + snap.lightmaps.size() + " lightmaps, "
- + snap.lights.size() + " lights");
+ if (DEBUG) {
+ if (PHASES)
+ System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, "
+ + snap.lightmaps.size() + " lightmaps, "
+ + snap.lights.size() + " lights, phased scheduling near-to-far");
+ else
+ System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, "
+ + snap.workItems.length + " work items, "
+ + snap.lightmaps.size() + " lightmaps, "
+ + snap.lights.size() + " lights");
+ }
}
private double lightSignature() {
public byte[] lightVisibility;
public int lightCount;
+ // --- Phased progressive GI state (-De3d.gi.phases, default on) ---
+
+ /**
+ * Total direct irradiance at the triangle centroid (ambient + direct
+ * with shadows, clamped to display range), computed in phase A. The
+ * whole triangle displays this uniform value until per-texel
+ * refinement reaches it in phase C.
+ */
+ public volatile float centroidR, centroidG, centroidB;
+
+ /**
+ * Indirect irradiance estimated at the centroid during phase B. Texel
+ * indirect arrays are seeded from this so bounce rays hitting a
+ * not-yet-refined triangle read a plausible uniform value.
+ */
+ public volatile float centroidIndirectR, centroidIndirectG, centroidIndirectB;
+
+ /**
+ * True once per-texel refinement (phase C) has reached this lightmap.
+ * Before that, composite updates fill the texture uniformly from the
+ * centroid values instead of recomputing per texel.
+ */
+ public volatile boolean texelPhase;
+
+ /** True once phase A has stamped the centroid direct value (GI threads only). */
+ public volatile boolean centroidReady;
+
+ /** Round-robin cursor over {@link #validTexels} in phase C (GI threads only). */
+ public int nextTexel;
+
+ /** Consecutive calm composite updates, drives phase C graduation (GI threads only). */
+ public int calmComposites;
+
+ /**
+ * Phase C: valid texels that have been sampled at least once. Graduation
+ * must not fire before this reaches {@code validTexels.length}: an
+ * unvisited texel still shows its centroid-seeded light visibility
+ * (e.g. "fully shadowed" if the centroid happened to sit in shadow),
+ * and a frozen seed survives forever as a dark polygon with lit detail
+ * only on the visited fraction.
+ */
+ public int texelsSampled;
+
+ /** Last composited centroid totals; skips redundant uniform recomposites. */
+ private float lastCompositeR = Float.NaN, lastCompositeG, lastCompositeB;
+
/** Double-buffered composite textures; the triangle shows one, GI fills the other. */
private final Texture[] buffers = new Texture[2];
private int shownBuffer;
java.util.Arrays.fill(estimateG, (float) INITIAL_IRRADIANCE);
java.util.Arrays.fill(estimateB, (float) INITIAL_IRRADIANCE);
sampleCounts = new short[width * height];
+ texelsSampled = 0;
+ calmComposites = 0;
final int[] valid = new int[width * height];
int count = 0;
}
}
+ /**
+ * Phase A: stamps the centroid direct irradiance onto the whole
+ * triangle at once — estimate arrays, composite texture and buffer
+ * swap in one go, so the polygon flips from the uniform medium start
+ * to its true flat lighting in a single visible step (the progressive
+ * "wave"). Per-texel refinement later glides away from this seed via
+ * the normal composite EMA.
+ *
+ * @param r total direct irradiance, red channel (light units)
+ * @param g green channel
+ * @param b blue channel
+ */
+ public void seedDisplay(final float r, final float g, final float b) {
+ java.util.Arrays.fill(estimateR, r);
+ java.util.Arrays.fill(estimateG, g);
+ java.util.Arrays.fill(estimateB, b);
+ lastCompositeR = r;
+ lastCompositeG = g;
+ lastCompositeB = b;
+ final Texture back = backTexture();
+ java.util.Arrays.fill(back.primaryBitmap.pixels, compositeSeedPixel(r, g, b));
+ back.resetResampledBitmapCache();
+ if (owner != null) {
+ owner.setTexture(back);
+ swapBuffers();
+ }
+ }
+
+ /** Uniform composite pixel for {@link #seedDisplay}. */
+ private int compositeSeedPixel(final float irrR, final float irrG, final float irrB) {
+ final int r = Math.min(255, (int) (irrR * baseColor.r / 255));
+ final int g = Math.min(255, (int) (irrG * baseColor.g / 255));
+ final int b = Math.min(255, (int) (irrB * baseColor.b / 255));
+ return 0xFF000000 | (r << 16) | (g << 8) | b;
+ }
+
+ /**
+ * Phase A: fills the per-texel visibility array uniformly with the
+ * centroid shadow-test results, so bounce rays hitting this triangle
+ * before per-texel refinement see plausible shadowed direct light.
+ * Assumes {@link #ensureLightCapacity} has run.
+ *
+ * @param centroidVisibility per-light visibility bytes (length = lightCount)
+ */
+ public void seedCentroidVisibility(final byte[] centroidVisibility) {
+ final int texelCount = width * height;
+ for (int t = 0; t < texelCount; t++)
+ System.arraycopy(centroidVisibility, 0, lightVisibility, t * lightCount, lightCount);
+ }
+
+ /**
+ * Phase B / pre-refinement composite: reseeds the display estimate
+ * uniformly from the centroid direct plus the current centroid
+ * indirect, and fills the texel indirect arrays from the centroid
+ * indirect so bounce-target reads stay uniform. Runs at composite
+ * cadence, not per sample.
+ */
+ public void compositeFromCentroid() {
+ final float totalR = centroidR + centroidIndirectR;
+ final float totalG = centroidG + centroidIndirectG;
+ final float totalB = centroidB + centroidIndirectB;
+ if (totalR == lastCompositeR && totalG == lastCompositeG && totalB == lastCompositeB)
+ return; // centroid values unchanged since the last stamp: nothing to do
+ lastCompositeR = totalR;
+ lastCompositeG = totalG;
+ lastCompositeB = totalB;
+ java.util.Arrays.fill(estimateR, totalR);
+ java.util.Arrays.fill(estimateG, totalG);
+ java.util.Arrays.fill(estimateB, totalB);
+ java.util.Arrays.fill(indirectR, centroidIndirectR);
+ java.util.Arrays.fill(indirectG, centroidIndirectG);
+ java.util.Arrays.fill(indirectB, centroidIndirectB);
+ final Texture back = backTexture();
+ final int[] pixels = back.primaryBitmap.pixels;
+ final int pixel = compositeSeedPixel(totalR, totalG, totalB);
+ java.util.Arrays.fill(pixels, pixel);
+ back.resetResampledBitmapCache();
+ if (owner != null) {
+ owner.setTexture(back);
+ swapBuffers();
+ }
+ }
+
+ /**
+ * Resets the phased-progression state for a new GI snapshot (scene or
+ * light change): back to the centroid phases, per-texel shadow tracing
+ * re-armed. The display estimate arrays deliberately keep their old
+ * values so the re-traced solution fades in instead of flashing.
+ */
+ public void resetPhasedState() {
+ texelPhase = false;
+ centroidReady = false;
+ nextTexel = 0;
+ calmComposites = 0;
+ texelsSampled = 0;
+ centroidIndirectR = 0;
+ centroidIndirectG = 0;
+ centroidIndirectB = 0;
+ lastCompositeR = Float.NaN;
+ java.util.Arrays.fill(sampleCounts, (short) 0);
+ }
+
/**
* The texture the triangle should show right now.
*
/** Unit surface normal, world space. */
public volatile float[] normal;
+ // --- Phased progressive GI bookkeeping (GI threads only) ---
+
+ /** Squared distance from the camera at snapshot build / last re-sort. */
+ public float distance;
+ /** Phase B/C: true once this entry's estimate has calmed down and it left the active window. */
+ public volatile boolean graduated;
+ /** Phase C: true once the composite estimate has fully glided to the frozen target (display freeze). */
+ public volatile boolean frozen;
+ /** Consecutive calm phase-B visits (GI threads only). */
+ public int calmVisits;
+ /** Centroid bounce sample count, drives the adaptive alpha in phase B (GI threads only). */
+ public int centroidSamples;
+
public Entry(final AbstractCoordinateShape polygon) {
this.polygon = polygon;
}
* Computed once during transform phase, used during paint phase.
*/
private final Color shadedColor = new Color();
+ /**
+ * Shaded color of the REVERSE side, computed with the negated normal.
+ * Used when backface culling is off and the camera sees the polygon's
+ * back: the back is a different surface and must not show the front
+ * side's lighting (an underside must not glow with the top's light).
+ */
+ private final Color backShadedColor = new Color();
+ /**
+ * Reusable negated normal for the reverse-side lighting pass.
+ */
+ private final Point3D cachedBackNormal = new Point3D();
/**
* Reusable point for polygon center calculation.
*/
return;
}
- // Use pre-computed shaded color (computed during transform phase)
- final Color paintColor = shadingEnabled ? shadedColor : color;
-
- // Z-buffer two-pass classification: opaque polygons paint in
- // pass 1 (depth test + write), translucent ones in pass 2
- // (depth test, no write — translucency must not occlude).
- // See RenderAggregator.paintSorted.
- final boolean alphaClass = paintColor.a != 255;
- if ((renderBuffer.depthPass == 1) == alphaClass)
- return;
-
// Get thread-local screen points array
final Point2D[] screenPoints = getScreenPoints(active.size());
final double[] cameraZ = getCameraZ(active.size());
cameraZ[i] = vertex.transformedCoordinate(renderBuffer).z;
}
+ // Facing from the signed screen area (same convention as the
+ // backface culling test below: >= 0 means the back side shows).
+ final double signedArea = (backfaceCulling || shadingEnabled)
+ ? calculateSignedArea(screenPoints, active.size())
+ : -1;
+
// Backface culling check
- if (backfaceCulling) {
- final double signedArea = calculateSignedArea(screenPoints, active.size());
- if (signedArea >= 0) {
- return;
- }
- }
+ if (backfaceCulling && signedArea >= 0)
+ return;
+
+ // Two-sided lighting: a visible back side paints with the
+ // reverse-lit color, never the front side's light.
+ final Color paintColor = shadingEnabled
+ ? (signedArea >= 0 ? backShadedColor : shadedColor)
+ : color;
+
+ // Z-buffer two-pass classification: opaque polygons paint in
+ // pass 1 (depth test + write), translucent ones in pass 2
+ // (depth test, no write — translucency must not occlude).
+ // See RenderAggregator.paintSorted.
+ final boolean alphaClass = paintColor.a != 255;
+ if ((renderBuffer.depthPass == 1) == alphaClass)
+ return;
// Mouse interaction
if (mouseInteractionController != null && renderBuffer.getMouseEvent() != null) {
);
renderingContext.lightingManager.computeLighting(
this, cachedCenter, cachedNormal, color, shadedColor);
+ if (!backfaceCulling) {
+ // Two-sided lighting: when the camera sees the polygon's
+ // back (culling off), that side is lit by its own facing
+ // direction, so relight with the negated normal.
+ cachedBackNormal.x = -cachedNormal.x;
+ cachedBackNormal.y = -cachedNormal.y;
+ cachedBackNormal.z = -cachedNormal.z;
+ renderingContext.lightingManager.computeLighting(
+ this, cachedCenter, cachedBackNormal, color, backShadedColor);
+ }
}
}
}
\ No newline at end of file
private void wrapLightmapped(final SolidPolygon polygon, final List<AbstractShape> out) {
final int vertexCount = polygon.getVertexCount();
if (vertexCount == 3) {
- out.add(wrapTriangle(polygon));
+ emitWrapped(polygon, out);
return;
}
// Fan: anchor vertex 0, then consecutive pairs
triangle.setShadingEnabled(polygon.isShadingEnabled());
triangle.setBackfaceCulling(polygon.isBackfaceCullingEnabled());
triangle.setMouseInteractionController(polygon.mouseInteractionController);
- out.add(wrapTriangle(triangle));
+ emitWrapped(triangle, out);
}
}
+ /**
+ * Emits one lightmapped triangle for the front side. When the source
+ * polygon is two-sided (backface culling off), also emits a plain
+ * solid triangle with reversed winding for the back side, and turns
+ * culling on for both halves: a lightmap only knows its front side,
+ * so without a real back surface the camera would see the front
+ * side's light through the polygon (undersides glowing with the
+ * top's light). The reversed solid renders with ordinary one-sided
+ * direct lighting — correctly dark where the front is lit — and each
+ * side's pixels are owned by exactly one surface, so nothing
+ * z-fights.
+ */
+ private void emitWrapped(final SolidPolygon triangle, final List<AbstractShape> out) {
+ final LightmappedTriangle front = wrapTriangle(triangle);
+ if (triangle.isBackfaceCullingEnabled()) {
+ out.add(front);
+ return;
+ }
+ front.setBackfaceCulling(true);
+ final SolidPolygon back = new SolidPolygon(
+ triangle.vertices.get(0).coordinate,
+ triangle.vertices.get(2).coordinate,
+ triangle.vertices.get(1).coordinate,
+ triangle.getColor());
+ back.setShadingEnabled(triangle.isShadingEnabled());
+ back.setBackfaceCulling(true);
+ back.setMouseInteractionController(triangle.mouseInteractionController);
+ out.add(front);
+ out.add(back);
+ }
+
/**
* Wraps one triangle into a lightmapped textured triangle with the
* same geometry, color and culling.
*/
- private AbstractCoordinateShape wrapTriangle(final SolidPolygon polygon) {
+ private LightmappedTriangle wrapTriangle(final SolidPolygon polygon) {
final Point3D a = polygon.vertices.get(0).coordinate;
final Point3D b = polygon.vertices.get(1).coordinate;
final Point3D c = polygon.vertices.get(2).coordinate;
--- /dev/null
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * Loader for Wavefront OBJ geometry files (with MTL material libraries).
+ *
+ * <p>Supported OBJ statements:</p>
+ * <ul>
+ * <li>{@code v x y z} — vertex positions (extra components ignored)</li>
+ * <li>{@code f v1 v2 v3 [v4 ...]} — faces; each index may be in any of
+ * the standard forms {@code v}, {@code v/vt}, {@code v//vn},
+ * {@code v/vt/vn}; negative indices are relative to the most recently
+ * defined vertex. Faces may reference vertices defined later in the
+ * file (batch-flushing exporters do this). Texture/normal indices are
+ * parsed past and ignored.</li>
+ * <li>{@code usemtl name} — selects the material for following faces</li>
+ * <li>{@code mtllib file [file ...]} — material library, resolved relative
+ * to the OBJ file (or via the caller-provided resolver)</li>
+ * <li>{@code o}, {@code g}, {@code s}, {@code vt}, {@code vn}, {@code #} —
+ * parsed past and ignored</li>
+ * </ul>
+ *
+ * <p>Supported MTL statements: {@code newmtl}, {@code Kd} (diffuse color),
+ * {@code d} (opacity) and {@code Tr} (transparency). Everything else is
+ * ignored.</p>
+ *
+ * <p>Coordinates are loaded verbatim — no axis conversion. Note the engine's
+ * world convention is "+Y points down", which matches OBJ exporters that
+ * wrote scenes for y-down pipelines; for standard "+Y up" exports rotate
+ * the resulting model 180° around X via its transform.</p>
+ *
+ * <p>Limitation: faces are rendered as convex polygons (fan triangulation);
+ * non-convex faces may render with overlapping triangles.</p>
+ *
+ * <p>Usage:</p>
+ * <pre>{@code
+ * ObjModel model = ObjLoader.load(Path.of("city1.obj"));
+ * model.setShadingEnabled(true);
+ * shapes.addShape(model);
+ * }</pre>
+ */
+public final class ObjLoader {
+
+ /** Utility class, not meant to be instantiated. */
+ private ObjLoader() {
+ }
+
+ /**
+ * Loads an OBJ file, resolving {@code mtllib} references against the
+ * file's own directory.
+ *
+ * @param objFile path to the .obj file
+ * @return the parsed model, ready to add to a scene
+ * @throws IOException on read errors, malformed statements, or a
+ * missing material library
+ */
+ public static ObjModel load(final Path objFile) throws IOException {
+ final Path directory = objFile.toAbsolutePath().getParent();
+ final Function<String, InputStream> resolver = name -> {
+ final Path direct = directory.resolve(name);
+ try {
+ if (Files.exists(direct)) {
+ return Files.newInputStream(direct);
+ }
+ // DOS-era exports often disagree with the filesystem on case.
+ try (DirectoryStream<Path> entries = Files.newDirectoryStream(directory)) {
+ for (final Path entry : entries) {
+ if (entry.getFileName().toString().equalsIgnoreCase(name)) {
+ return Files.newInputStream(entry);
+ }
+ }
+ }
+ return null;
+ } catch (final IOException e) {
+ return null;
+ }
+ };
+ try (InputStream stream = Files.newInputStream(objFile)) {
+ return load(stream, objFile.getFileName().toString(), resolver);
+ }
+ }
+
+ /**
+ * Loads an OBJ document from a stream.
+ *
+ * @param objStream the OBJ content
+ * @param sourceName name used in error messages
+ * @param resourceResolver opens referenced material libraries by name;
+ * may return {@code null} when not found
+ * @return the parsed model, ready to add to a scene
+ * @throws IOException on read errors or malformed statements
+ */
+ public static ObjModel load(final InputStream objStream, final String sourceName,
+ final Function<String, InputStream> resourceResolver)
+ throws IOException {
+ final List<Point3D> vertices = new ArrayList<>();
+ final Map<String, ObjMaterial> materials = new HashMap<>();
+ final Set<String> usedMaterials = new HashSet<>();
+ final List<PendingFace> faces = new ArrayList<>();
+
+ ObjMaterial currentMaterial = ObjMaterial.DEFAULT;
+
+ final BufferedReader reader = new BufferedReader(
+ new InputStreamReader(objStream, StandardCharsets.UTF_8));
+ String line;
+ int lineNumber = 0;
+ // OBJ line continuation: a trailing backslash joins with the next line.
+ StringBuilder pending = new StringBuilder();
+ while ((line = reader.readLine()) != null) {
+ lineNumber++;
+ final String trimmed = line.trim();
+ if (trimmed.endsWith("\\")) {
+ pending.append(trimmed, 0, trimmed.length() - 1).append(' ');
+ continue;
+ }
+ pending.append(trimmed);
+ final String statement = pending.toString();
+ pending.setLength(0);
+
+ if (statement.isEmpty() || statement.startsWith("#")) {
+ continue;
+ }
+ final String[] tokens = statement.split("\\s+");
+ switch (tokens[0]) {
+ case "v":
+ vertices.add(new Point3D(
+ parseDouble(tokens, 1, sourceName, lineNumber),
+ parseDouble(tokens, 2, sourceName, lineNumber),
+ parseDouble(tokens, 3, sourceName, lineNumber)));
+ break;
+ case "f": {
+ if (tokens.length < 4) {
+ throw malformed(sourceName, lineNumber,
+ "face needs at least 3 vertices: " + statement);
+ }
+ // Positive indices are validated and resolved only after
+ // the whole file is parsed: exporters that flush vertices
+ // in batches (e.g. 3D Synthezier's 3dparse) legally emit
+ // faces referencing vertices defined LATER in the file.
+ // Negative (relative) indices resolve immediately — they
+ // are relative to the vertex count at this point.
+ final int[] indices = new int[tokens.length - 1];
+ for (int i = 1; i < tokens.length; i++) {
+ indices[i - 1] = resolveIndex(tokens[i], vertices.size(),
+ sourceName, lineNumber);
+ }
+ faces.add(new PendingFace(indices, currentMaterial, lineNumber));
+ usedMaterials.add(currentMaterial.getName());
+ break;
+ }
+ case "usemtl":
+ if (tokens.length < 2) {
+ throw malformed(sourceName, lineNumber, "usemtl needs a name");
+ }
+ currentMaterial = materials.getOrDefault(tokens[1], ObjMaterial.DEFAULT);
+ break;
+ case "mtllib":
+ if (tokens.length < 2) {
+ throw malformed(sourceName, lineNumber, "mtllib needs a file name");
+ }
+ for (int i = 1; i < tokens.length; i++) {
+ loadMaterialLibrary(tokens[i], sourceName, resourceResolver, materials);
+ }
+ break;
+ default:
+ // o, g, s, vt, vn, ... — not needed for flat-colored polygons.
+ break;
+ }
+ }
+
+ final ObjModel result = new ObjModel(vertices.size(), faces.size(),
+ usedMaterials.size());
+ for (final PendingFace face : faces) {
+ final Point3D[] facePoints = new Point3D[face.indices.length];
+ for (int i = 0; i < face.indices.length; i++) {
+ final int index = face.indices[i];
+ if (index >= vertices.size()) {
+ throw malformed(sourceName, face.lineNumber,
+ "face index out of range: " + (index + 1));
+ }
+ // Copy: faces must not alias one mutable Point3D.
+ facePoints[i] = new Point3D(vertices.get(index));
+ }
+ result.addShape(new SolidPolygon(facePoints, face.material.getColor()));
+ }
+ return result;
+ }
+
+ /** A face statement captured mid-parse, before vertices are complete. */
+ private static final class PendingFace {
+ private final int[] indices;
+ private final ObjMaterial material;
+ private final int lineNumber;
+
+ private PendingFace(final int[] indices, final ObjMaterial material,
+ final int lineNumber) {
+ this.indices = indices;
+ this.material = material;
+ this.lineNumber = lineNumber;
+ }
+ }
+
+ private static void loadMaterialLibrary(final String fileName, final String sourceName,
+ final Function<String, InputStream> resourceResolver,
+ final Map<String, ObjMaterial> materials)
+ throws IOException {
+ try (InputStream stream = resourceResolver.apply(fileName)) {
+ if (stream == null) {
+ throw new IOException(sourceName + ": material library not found: " + fileName);
+ }
+ parseMaterialLibrary(stream, fileName, materials);
+ }
+ }
+
+ private static void parseMaterialLibrary(final InputStream stream, final String fileName,
+ final Map<String, ObjMaterial> materials)
+ throws IOException {
+ final BufferedReader reader = new BufferedReader(
+ new InputStreamReader(stream, StandardCharsets.UTF_8));
+ String name = null;
+ double r = 0.5, g = 0.5, b = 0.5, opacity = 1.0;
+ String line;
+ int lineNumber = 0;
+ while ((line = reader.readLine()) != null) {
+ lineNumber++;
+ final String trimmed = line.trim();
+ if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+ continue;
+ }
+ final String[] tokens = trimmed.split("\\s+");
+ switch (tokens[0]) {
+ case "newmtl":
+ if (name != null) {
+ materials.put(name, new ObjMaterial(name,
+ new Color(r, g, b, opacity)));
+ }
+ if (tokens.length < 2) {
+ throw malformed(fileName, lineNumber, "newmtl needs a name");
+ }
+ name = tokens[1];
+ r = g = b = 0.5;
+ opacity = 1.0;
+ break;
+ case "Kd":
+ r = parseDouble(tokens, 1, fileName, lineNumber);
+ g = parseDouble(tokens, 2, fileName, lineNumber);
+ b = parseDouble(tokens, 3, fileName, lineNumber);
+ break;
+ case "d":
+ opacity = parseDouble(tokens, 1, fileName, lineNumber);
+ break;
+ case "Tr":
+ opacity = 1.0 - parseDouble(tokens, 1, fileName, lineNumber);
+ break;
+ default:
+ // Ns, Ks, Ka, illum, map_*, ... — ignored.
+ break;
+ }
+ }
+ if (name != null) {
+ materials.put(name, new ObjMaterial(name, new Color(r, g, b, opacity)));
+ }
+ }
+
+ /**
+ * Resolves an OBJ face index token ({@code v}, {@code v/vt}, {@code v//vn}
+ * or {@code v/vt/vn}) to a 0-based index. Positive indices (1-based) are
+ * returned as-is minus one WITHOUT a range check — the exporter may define
+ * the referenced vertex later in the file; the range is validated against
+ * the final vertex count when the model is assembled. Negative indices are
+ * relative to the vertex count at this point and are range-checked
+ * immediately.
+ */
+ private static int resolveIndex(final String token, final int vertexCount,
+ final String sourceName, final int lineNumber)
+ throws IOException {
+ final int slash = token.indexOf('/');
+ final String indexText = slash < 0 ? token : token.substring(0, slash);
+ final int raw;
+ try {
+ raw = Integer.parseInt(indexText);
+ } catch (final NumberFormatException e) {
+ throw malformed(sourceName, lineNumber, "bad face index: " + token);
+ }
+ if (raw == 0) {
+ throw malformed(sourceName, lineNumber,
+ "face index out of range: " + token);
+ }
+ if (raw < 0) {
+ final int index = vertexCount + raw;
+ if (index < 0 || index >= vertexCount) {
+ throw malformed(sourceName, lineNumber,
+ "face index out of range: " + token);
+ }
+ return index;
+ }
+ return raw - 1;
+ }
+
+ private static double parseDouble(final String[] tokens, final int position,
+ final String sourceName, final int lineNumber)
+ throws IOException {
+ if (position >= tokens.length) {
+ throw malformed(sourceName, lineNumber,
+ "expected number at position " + (position + 1));
+ }
+ try {
+ return Double.parseDouble(tokens[position]);
+ } catch (final NumberFormatException e) {
+ throw malformed(sourceName, lineNumber, "bad number: " + tokens[position]);
+ }
+ }
+
+ private static IOException malformed(final String sourceName, final int lineNumber,
+ final String detail) {
+ return new IOException(sourceName + ":" + lineNumber + ": " + detail);
+ }
+}
--- /dev/null
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+/**
+ * A single material from a Wavefront MTL library.
+ *
+ * <p>Only the subset of MTL attributes relevant to the rasterizer is
+ * captured: diffuse color ({@code Kd}) and opacity ({@code d}, or
+ * {@code Tr} transparency) — combined into the engine's {@link Color}
+ * (alpha 255 = opaque, lower = translucent). Specular/ambient/texture-map
+ * attributes ({@code Ks}, {@code Ka}, {@code Ns}, {@code illum},
+ * {@code map_*}) are parsed past and ignored.</p>
+ */
+public class ObjMaterial {
+
+ /** Fallback material for faces whose {@code usemtl} is unknown or missing. */
+ public static final ObjMaterial DEFAULT = new ObjMaterial("default",
+ new Color(160, 160, 160, 255));
+
+ private final String name;
+ private final Color color;
+
+ public ObjMaterial(final String name, final Color color) {
+ this.name = name;
+ this.color = color;
+ }
+
+ /** Material name from the {@code newmtl} statement. */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Diffuse color ({@code Kd}) with alpha taken from {@code d}
+ * (1.0 → alpha 255 opaque, lower values → translucent).
+ */
+ public Color getColor() {
+ return color;
+ }
+}
--- /dev/null
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape;
+
+/**
+ * A 3D model loaded from a Wavefront OBJ file, as a composite of
+ * solid polygons (one {@code SolidPolygon} per OBJ face, colored by the
+ * face's {@code usemtl} material).
+ *
+ * <p>Instances are built by {@link ObjLoader}; treat the model as an
+ * ordinary composite: position it with {@link #setTransform}, toggle
+ * {@link #setShadingEnabled} / {@link #setBackfaceCulling}, and add it to
+ * the scene graph.</p>
+ *
+ * @see ObjLoader
+ */
+public class ObjModel extends LightmappedCompositeShape {
+
+ /** Number of {@code v} statements in the source file. */
+ private final int sourceVertexCount;
+
+ /** Number of {@code f} statements (one child polygon each). */
+ private final int faceCount;
+
+ /** Number of materials actually referenced by faces. */
+ private final int usedMaterialCount;
+
+ public ObjModel(final int sourceVertexCount, final int faceCount,
+ final int usedMaterialCount) {
+ super();
+ this.sourceVertexCount = sourceVertexCount;
+ this.faceCount = faceCount;
+ this.usedMaterialCount = usedMaterialCount;
+ }
+
+ /** Number of vertices declared in the OBJ source. */
+ public int getSourceVertexCount() {
+ return sourceVertexCount;
+ }
+
+ /** Number of faces (= child polygons) in the model. */
+ public int getFaceCount() {
+ return faceCount;
+ }
+
+ /** Number of distinct materials referenced by the model's faces. */
+ public int getUsedMaterialCount() {
+ return usedMaterialCount;
+ }
+}
--- /dev/null
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Wavefront OBJ/MTL model loading: {@link ObjLoader} parses the files and
+ * builds an {@link ObjModel} composite of solid polygons, colored per face
+ * from the material library.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
assertEquals(p.y, result.y, EPSILON);
assertEquals(p.z, result.z, EPSILON);
}
+
+ @Test
+ public void uniformScaleComposesThroughStack() {
+ // Parent scales 2x, child scales 3x, both rotated and translated:
+ // the composed stack must match sequential per-level application,
+ // where each level applies scale, then rotation, then translation.
+ final Random rnd = new Random(7);
+
+ for (int depth = 2; depth <= 5; depth++) {
+ final Transform[] chain = new Transform[depth];
+ final TransformStack stack = new TransformStack();
+ for (int i = 0; i < depth; i++) {
+ chain[i] = Transform.fromAngles(
+ (rnd.nextDouble() - 0.5) * 1000,
+ (rnd.nextDouble() - 0.5) * 1000,
+ (rnd.nextDouble() - 0.5) * 1000,
+ (rnd.nextDouble() - 0.5) * Math.PI * 2,
+ (rnd.nextDouble() - 0.5) * Math.PI,
+ (rnd.nextDouble() - 0.5) * Math.PI);
+ chain[i].setScale(0.5 + rnd.nextDouble() * 3);
+ stack.addTransform(chain[i]);
+ }
+
+ for (int k = 0; k < 50; k++) {
+ final Point3D p = new Point3D(
+ (rnd.nextDouble() - 0.5) * 100,
+ (rnd.nextDouble() - 0.5) * 100,
+ (rnd.nextDouble() - 0.5) * 100);
+
+ final Point3D expected = new Point3D(p);
+ for (int i = depth - 1; i >= 0; i--) {
+ chain[i].transform(expected);
+ }
+
+ final Point3D result = new Point3D();
+ stack.transform(p, result);
+
+ // Scaled coordinates amplify absolute error, so use a
+ // relative-ish tolerance sized by the accumulated scale.
+ assertEquals("depth " + depth + " x", expected.x, result.x, 1e-6 * magnitude(expected));
+ assertEquals("depth " + depth + " y", expected.y, result.y, 1e-6 * magnitude(expected));
+ assertEquals("depth " + depth + " z", expected.z, result.z, 1e-6 * magnitude(expected));
+ }
+ }
+ }
+
+ private static double magnitude(final Point3D p) {
+ return Math.max(1.0, Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z));
+ }
+
+ @Test
+ public void scaleAppliesAboutLocalOrigin() {
+ // Scale first, then rotation (identity here), then translation:
+ // the local point is scaled, the translation is not.
+ final Transform t = Transform.fromAngles(10, 20, 30, 0, 0, 0);
+ t.setScale(2.0);
+
+ final Point3D p = new Point3D(3, 4, 5);
+ t.transform(p);
+
+ assertEquals(16, p.x, EPSILON);
+ assertEquals(28, p.y, EPSILON);
+ assertEquals(40, p.z, EPSILON);
+ }
+
+ @Test
+ public void nestedScalesMultiply() {
+ // No rotations: point (1,0,0) -> child: 1*3 + 10 = 13
+ // -> parent: 13*2 + 100 = 126.
+ final Transform parent = Transform.fromAngles(100, 0, 0, 0, 0, 0);
+ parent.setScale(2.0);
+ final Transform child = Transform.fromAngles(10, 0, 0, 0, 0, 0);
+ child.setScale(3.0);
+
+ final TransformStack stack = new TransformStack();
+ stack.addTransform(parent);
+ stack.addTransform(child);
+
+ final Point3D result = new Point3D();
+ stack.transform(new Point3D(1, 0, 0), result);
+
+ assertEquals(126, result.x, EPSILON);
+ assertEquals(0, result.y, EPSILON);
+ assertEquals(0, result.z, EPSILON);
+ }
+
+ @Test
+ public void negativeScaleMirrors() {
+ final Transform t = Transform.fromAngles(1, 2, 3, 0, 0, 0);
+ t.setScale(-2.0);
+
+ final Point3D p = new Point3D(1, 1, 1);
+ t.transform(p);
+
+ assertEquals(-1, p.x, EPSILON);
+ assertEquals(0, p.y, EPSILON);
+ assertEquals(1, p.z, EPSILON);
+ }
+
+ @Test
+ public void setScaleRejectsZeroAndNonFinite() {
+ final Transform t = new Transform();
+ for (final double bad : new double[]{0.0, Double.NaN,
+ Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) {
+ try {
+ t.setScale(bad);
+ throw new AssertionError("expected IllegalArgumentException for " + bad);
+ } catch (final IllegalArgumentException expected) {
+ // expected
+ }
+ }
+ assertEquals(1.0, t.getScale(), 0.0);
+ }
+
+ @Test
+ public void explicitScaleOneIsBitIdenticalToDefault() {
+ // The fold multiplies by the scale at push time; with s == 1.0 that
+ // multiply must be IEEE-exact so rigid scenes stay bit-identical.
+ final Random rnd = new Random(99);
+
+ for (int depth = 1; depth <= 4; depth++) {
+ final TransformStack plain = new TransformStack();
+ final TransformStack explicit = new TransformStack();
+ for (int i = 0; i < depth; i++) {
+ final double x = (rnd.nextDouble() - 0.5) * 1000;
+ final double y = (rnd.nextDouble() - 0.5) * 1000;
+ final double z = (rnd.nextDouble() - 0.5) * 1000;
+ final double yaw = (rnd.nextDouble() - 0.5) * Math.PI * 2;
+ final double pitch = (rnd.nextDouble() - 0.5) * Math.PI;
+ final double roll = (rnd.nextDouble() - 0.5) * Math.PI;
+
+ plain.addTransform(Transform.fromAngles(x, y, z, yaw, pitch, roll));
+ explicit.addTransform(Transform.fromAngles(x, y, z, yaw, pitch, roll)
+ .setScale(1.0));
+ }
+
+ for (int k = 0; k < 20; k++) {
+ final Point3D p = new Point3D(
+ (rnd.nextDouble() - 0.5) * 2000,
+ (rnd.nextDouble() - 0.5) * 2000,
+ (rnd.nextDouble() - 0.5) * 2000);
+ final Point3D a = new Point3D();
+ final Point3D b = new Point3D();
+ plain.transform(p, a);
+ explicit.transform(p, b);
+
+ assertEquals(0.0, Double.compare(a.x, b.x), 0.0);
+ assertEquals(0.0, Double.compare(a.y, b.y), 0.0);
+ assertEquals(0.0, Double.compare(a.z, b.z), 0.0);
+ }
+ }
+ }
+
+ @Test
+ public void scaleMutationAfterPushHasNoEffect() {
+ final Transform transform = Transform.fromAngles(10, 0, 0, 0, 0, 0);
+ transform.setScale(3.0);
+
+ final TransformStack stack = new TransformStack();
+ stack.addTransform(transform);
+
+ // Push-time snapshot: later scale changes must not leak in.
+ transform.setScale(100.0);
+
+ final Point3D result = new Point3D();
+ stack.transform(new Point3D(1, 0, 0), result);
+
+ assertEquals(13, result.x, EPSILON);
+ }
+
+ @Test
+ public void topTransformCarriesScale() {
+ // getTopTransform is the bulk path used by TriangleMeshBlock; it must
+ // agree with transform() bit-for-bit, scale included.
+ final Transform parent = Transform.fromAngles(50, -20, 70, 0.4, -0.2, 0.1);
+ parent.setScale(2.5);
+ final Transform child = Transform.fromAngles(-5, 8, 3, -0.3, 0.7, 0.2);
+ child.setScale(0.75);
+
+ final TransformStack stack = new TransformStack();
+ stack.addTransform(parent);
+ stack.addTransform(child);
+
+ final double[] m = new double[12];
+ stack.getTopTransform(m);
+
+ final Random rnd = new Random(5);
+ for (int k = 0; k < 50; k++) {
+ final double x = (rnd.nextDouble() - 0.5) * 100;
+ final double y = (rnd.nextDouble() - 0.5) * 100;
+ final double z = (rnd.nextDouble() - 0.5) * 100;
+
+ final Point3D viaStack = new Point3D();
+ stack.transform(new Point3D(x, y, z), viaStack);
+
+ final double bx = m[0] * x + m[1] * y + m[2] * z + m[9];
+ final double by = m[3] * x + m[4] * y + m[5] * z + m[10];
+ final double bz = m[6] * x + m[7] * y + m[8] * z + m[11];
+
+ assertEquals(0.0, Double.compare(viaStack.x, bx), 0.0);
+ assertEquals(0.0, Double.compare(viaStack.y, by), 0.0);
+ assertEquals(0.0, Double.compare(viaStack.z, bz), 0.0);
+ }
+ }
}
--- /dev/null
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Unit tests for {@link ObjLoader}: statement parsing, face index forms,
+ * material resolution, alpha, and error reporting.
+ */
+public class ObjLoaderTest {
+
+ private static final String MTL = """
+ # comment
+ newmtl red
+ Kd 1.0 0.0 0.0
+ d 1.0
+
+ newmtl glass
+ Kd 0.0 0.0 1.0
+ d 0.5
+ """;
+
+ private static Map<String, String> mtlFiles() {
+ final Map<String, String> files = new HashMap<>();
+ files.put("test.mtl", MTL);
+ return files;
+ }
+
+ private static ObjModel load(final String obj, final Map<String, String> files)
+ throws IOException {
+ return ObjLoader.load(
+ new ByteArrayInputStream(obj.getBytes(StandardCharsets.UTF_8)),
+ "test.obj",
+ name -> {
+ final String content = files.get(name);
+ return content == null ? null
+ : new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+ });
+ }
+
+ @Test
+ public void parsesQuadAndTriangleWithMaterials() throws IOException {
+ final ObjModel model = load("""
+ mtllib test.mtl
+ v 0 0 0
+ v 10 0 0
+ v 10 10 0
+ v 0 10 0
+ v 20 0 0
+ usemtl red
+ f 1 2 3 4
+ usemtl glass
+ f 2 5 3
+ """, mtlFiles());
+
+ assertEquals(5, model.getSourceVertexCount());
+ assertEquals(2, model.getFaceCount());
+ assertEquals(2, model.getUsedMaterialCount());
+
+ final List<SolidPolygon> polygons = model.extractSolidPolygons();
+ assertEquals(2, polygons.size());
+
+ // Winding/vertex order must be preserved verbatim.
+ final SolidPolygon quad = polygons.get(0);
+ assertEquals(4, quad.vertices.size());
+ assertEquals(10.0, quad.vertices.get(2).coordinate.x, 1e-9);
+ assertEquals(10.0, quad.vertices.get(2).coordinate.y, 1e-9);
+ assertEquals(255, quad.getColor().r);
+ assertEquals(255, quad.getColor().a);
+
+ final SolidPolygon triangle = polygons.get(1);
+ assertEquals(3, triangle.vertices.size());
+ assertEquals(255, triangle.getColor().b);
+ // d 0.5 -> alpha ~127/128 (0.5*255 truncation is allowed either way)
+ assertTrue(Math.abs(triangle.getColor().a - 127) <= 1);
+ }
+
+ @Test
+ public void acceptsAllFaceIndexForms() throws IOException {
+ final ObjModel model = load("""
+ v 0 0 0 1.0 0.5 0.5
+ v 1 0 0
+ v 1 1 0
+ v 0 1 0
+ vt 0 0
+ vn 0 0 1
+ f 1 2 3
+ f 1/1 2/1 3/1
+ f 1//1 2//1 3//1
+ f 1/1/1 2/1/1 3/1/1
+ f -4 -3 -2 -1
+ """, mtlFiles());
+ assertEquals(5, model.getFaceCount());
+ // Negative indices are relative: -4..-1 == vertices 1..4 (0,0,0)..(0,1,0).
+ final SolidPolygon last = model.extractSolidPolygons().get(4);
+ assertEquals(0.0, last.vertices.get(0).coordinate.x, 1e-9);
+ assertEquals(0.0, last.vertices.get(3).coordinate.x, 1e-9);
+ assertEquals(1.0, last.vertices.get(3).coordinate.y, 1e-9);
+ }
+
+ @Test
+ public void unknownMaterialFallsBackToDefault() throws IOException {
+ final ObjModel model = load("""
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ usemtl nope
+ f 1 2 3
+ """, mtlFiles());
+ final SolidPolygon polygon = model.extractSolidPolygons().get(0);
+ assertEquals(160, polygon.getColor().r);
+ assertEquals(255, polygon.getColor().a);
+ }
+
+ @Test
+ public void faceWithoutAnyMaterialUsesDefault() throws IOException {
+ final ObjModel model = load("""
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ f 1 2 3
+ """, mtlFiles());
+ assertEquals(160, model.extractSolidPolygons().get(0).getColor().r);
+ }
+
+ @Test
+ public void forwardVertexReferencesResolve() throws IOException {
+ // Batch-flushing exporters (3D Synthezier's 3dparse) emit faces that
+ // reference vertices defined later in the file — valid OBJ.
+ final ObjModel model = load("""
+ f 1 2 3
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ """, mtlFiles());
+ assertEquals(1, model.getFaceCount());
+ final SolidPolygon polygon = model.extractSolidPolygons().get(0);
+ assertEquals(1.0, polygon.vertices.get(2).coordinate.x, 1e-9);
+ assertEquals(1.0, polygon.vertices.get(2).coordinate.y, 1e-9);
+ }
+
+ @Test
+ public void rejectsOutOfRangeFaceIndex() throws IOException {
+ try {
+ load("""
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ f 1 2 4
+ """, mtlFiles());
+ fail("expected IOException for out-of-range face index");
+ } catch (final IOException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("test.obj:4"));
+ }
+ }
+
+ @Test(expected = IOException.class)
+ public void rejectsDegenerateFace() throws IOException {
+ load("""
+ v 0 0 0
+ v 1 0 0
+ f 1 2
+ """, mtlFiles());
+ }
+
+ @Test
+ public void missingMaterialLibraryIsAnError() throws IOException {
+ try {
+ load("""
+ mtllib absent.mtl
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ f 1 2 3
+ """, mtlFiles());
+ fail("expected IOException for missing material library");
+ } catch (final IOException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("absent.mtl"));
+ }
+ }
+
+ @Test
+ public void trTransparencyComplementsOpacity() throws IOException {
+ final Map<String, String> files = new HashMap<>();
+ files.put("tr.mtl", "newmtl half\nKd 0 1 0\nTr 0.25\n");
+ final ObjModel model = load("""
+ mtllib tr.mtl
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ usemtl half
+ f 1 2 3
+ """, files);
+ assertEquals(191, model.extractSolidPolygons().get(0).getColor().a);
+ assertEquals(255, model.extractSolidPolygons().get(0).getColor().g);
+ }
+
+ @Test
+ public void lineContinuationJoinsStatements() throws IOException {
+ final ObjModel model = load("v 0 0 \\\n0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n", mtlFiles());
+ assertEquals(3, model.getSourceVertexCount());
+ assertEquals(1, model.getFaceCount());
+ }
+
+ @Test
+ public void pathLoadResolvesMtllibCaseInsensitively() throws IOException {
+ final Path dir = Files.createTempDirectory("objloader-test");
+ final Path obj = dir.resolve("scene.obj");
+ Files.writeString(obj, """
+ mtllib materials.mtl
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ usemtl green
+ f 1 2 3
+ """);
+ // Filesystem has a different case than the mtllib reference.
+ Files.writeString(dir.resolve("MATERIALS.MTL"), "newmtl green\nKd 0 1 0\nd 1\n");
+
+ final ObjModel model = ObjLoader.load(obj);
+ assertEquals(1, model.getFaceCount());
+ assertEquals(255, model.extractSolidPolygons().get(0).getColor().g);
+ }
+}
--- /dev/null
+ACTION=="add|change", SUBSYSTEM=="hidraw", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1bbb", ATTRS{idProduct}=="af50", MODE="0666"
--- /dev/null
+SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c626", MODE="0666"