From 9ca140487324ef91a0bf02bb64ea2e40f4195ab5 Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Sun, 27 Sep 2026 22:02:50 +0300 Subject: [PATCH] feat: phased progressive GI scheduling and Wavefront OBJ loading Replace the flat round-robin GI scheduler with a three-phase progressive pipeline ordered near-to-far from the camera: phase A stamps ambient + direct light at polygon centroids so the scene lights up as a visible wave; phase B converges multi-bounce indirect light at centroids scene-wide; phase C refines lightmaps per texel. Phases B and C run on a bounded 256-entry active window that graduates calm entries, re-sorting pending work when the camera moves more than 25 world units. Graduated lightmaps keep recompositing through a display-freeze tail so no centroid-stamp bias survives as visible seams. The legacy scheduler stays available via -De3d.gi.phases=false. ViewPanel now feeds the camera position into the GI system, and a torn-read guard prevents converging partial snapshots. Add a Wavefront OBJ loader (ObjLoader/ObjModel/ObjMaterial): vertices, faces in all index forms including negative and forward references, usemtl/mtllib resolution, and MTL Kd diffuse color with d/Tr opacity. Faces become SolidPolygons inside an ObjModel composite, so shading, culling and transforms work as for any composite. Extend Transform with a uniform scale applied before rotation (negative values mirror geometry and flip winding; zero and non-finite values are rejected). TransformStack bakes the scale into the composed matrix at push time, so per-vertex render cost is unchanged and unscaled scenes render bit-identically. Light two-sided polygons per side: SolidPolygon computes a reverse-side shaded color from the negated normal, and LightmappedCompositeShape emits a reversed solid back triangle for unculled polygons so undersides no longer show the front side's light. Add udev rules for RayNeo XR glasses and the 3Dconnexion SpaceNavigator, tests for transform scaling and OBJ loading, and documentation pages for octree ray tracing, GUI components, SpaceMouse input, configuration and subpixel culling. --- AGENTS.org | 31 +- Documentation/Configuration/Atomic write.svg | 57 + .../Configuration/Config precedence.svg | 36 + Documentation/Configuration/index.org | 137 ++ .../GUI components/Event dispatch.svg | 56 + Documentation/GUI components/Focus model.svg | 43 + .../GUI components/Texture UV picking.svg | 49 + Documentation/GUI components/index.org | 166 +++ Documentation/Global illumination/index.org | 107 +- .../Octree ray tracing/Cell pool.svg | 67 + .../Octree ray tracing/Octree subdivision.svg | 63 + .../Octree ray tracing/Ray traversal.svg | 64 + Documentation/Octree ray tracing/index.org | 140 ++ Documentation/Rendering loop/index.org | 50 +- .../SpaceMouse 6DOF/SpaceMouse gestures.svg | 48 + Documentation/SpaceMouse 6DOF/SpaceMouse.png | Bin 0 -> 113975 bytes Documentation/SpaceMouse 6DOF/index.org | 110 ++ .../Stereoscopic rendering/Head tracking.svg | 46 + .../Stereoscopic rendering/Stereo per eye.svg | 18 +- .../Stereoscopic rendering/index.org | 71 + .../Subpixel culling/Culling ladder.svg | 38 + .../Subpixel culling/Verdict epoch.svg | 46 + Documentation/Subpixel culling/index.org | 115 ++ Documentation/index.org | 128 +- .../svjatoslav/aukio/e3d/gui/ViewPanel.java | 3 +- .../svjatoslav/aukio/e3d/math/Transform.java | 67 +- .../aukio/e3d/math/TransformStack.java | 67 +- .../raster/gi/GlobalIllumination.java | 1286 +++++++++++++---- .../e3d/renderer/raster/gi/Lightmap.java | 150 ++ .../e3d/renderer/raster/gi/TriangleBvh.java | 13 + .../basic/solidpolygon/SolidPolygon.java | 60 +- .../composite/LightmappedCompositeShape.java | 37 +- .../shapes/composite/obj/ObjLoader.java | 341 +++++ .../shapes/composite/obj/ObjMaterial.java | 45 + .../raster/shapes/composite/obj/ObjModel.java | 54 + .../shapes/composite/obj/package-info.java | 11 + .../aukio/e3d/math/TransformStackTest.java | 204 +++ .../shapes/composite/obj/ObjLoaderTest.java | 241 +++ udev/99-rayneo-glasses.rules | 1 + udev/99-spacenavigator.rules | 1 + 40 files changed, 3877 insertions(+), 390 deletions(-) create mode 100644 Documentation/Configuration/Atomic write.svg create mode 100644 Documentation/Configuration/Config precedence.svg create mode 100644 Documentation/Configuration/index.org create mode 100644 Documentation/GUI components/Event dispatch.svg create mode 100644 Documentation/GUI components/Focus model.svg create mode 100644 Documentation/GUI components/Texture UV picking.svg create mode 100644 Documentation/GUI components/index.org create mode 100644 Documentation/Octree ray tracing/Cell pool.svg create mode 100644 Documentation/Octree ray tracing/Octree subdivision.svg create mode 100644 Documentation/Octree ray tracing/Ray traversal.svg create mode 100644 Documentation/Octree ray tracing/index.org create mode 100644 Documentation/SpaceMouse 6DOF/SpaceMouse gestures.svg create mode 100644 Documentation/SpaceMouse 6DOF/SpaceMouse.png create mode 100644 Documentation/SpaceMouse 6DOF/index.org create mode 100644 Documentation/Stereoscopic rendering/Head tracking.svg create mode 100644 Documentation/Subpixel culling/Culling ladder.svg create mode 100644 Documentation/Subpixel culling/Verdict epoch.svg create mode 100644 Documentation/Subpixel culling/index.org create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoader.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjMaterial.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjModel.java create mode 100644 src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/package-info.java create mode 100644 src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoaderTest.java create mode 100644 udev/99-rayneo-glasses.rules create mode 100644 udev/99-spacenavigator.rules diff --git a/AGENTS.org b/AGENTS.org index 675193a..cd5e17b 100644 --- a/AGENTS.org +++ b/AGENTS.org @@ -559,19 +559,24 @@ rebuild the exact demo scene headlessly. :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). diff --git a/Documentation/Configuration/Atomic write.svg b/Documentation/Configuration/Atomic write.svg new file mode 100644 index 0000000..123c7d0 --- /dev/null +++ b/Documentation/Configuration/Atomic write.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + Writes: lock, re-read, merge, atomic rename + + + + setString + (app or UI) + + + same-JVM + writeMonitor + + + sidecar lock + <file>.lock + + + fresh re-read + under the lock + + + merge key + into YAML + + + tmp + atomic + rename + + + + + + + + + the FileLock serializes cooperating JVMs; the same-JVM monitor serializes threads + a hand edit made between the writer's read and write survives the merge + unknown keys and other sections are preserved verbatim + + caveat: SnakeYAML rewrites the file on the first set - human-written header comments are lost + caveat: an editor saving a stale buffer over newer YAML still clobbers app writes + diff --git a/Documentation/Configuration/Config precedence.svg b/Documentation/Configuration/Config precedence.svg new file mode 100644 index 0000000..f3343b5 --- /dev/null +++ b/Documentation/Configuration/Config precedence.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + Three layers; the topmost one that has the key wins + + + + key missing + -> fall through + + + + system property: -De3d.ipd=6.3 ← WINS + + + config file: ~/.config/aukio/config.yaml (e3d: section) + + + built-in defaults (in code) + + a missing key - or a missing file - falls through to the layer below + file location override: -Daukio.config=/path/to.yaml (legacy fallback: -De3d.config=) + diff --git a/Documentation/Configuration/index.org b/Documentation/Configuration/index.org new file mode 100644 index 0000000..61c9039 --- /dev/null +++ b/Documentation/Configuration/index.org @@ -0,0 +1,137 @@ +: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: + +[[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]] diff --git a/Documentation/GUI components/Event dispatch.svg b/Documentation/GUI components/Event dispatch.svg new file mode 100644 index 0000000..12e8324 --- /dev/null +++ b/Documentation/GUI components/Event dispatch.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + From AWT event to component callback + + + + + AWT event + press, release, move, wheel + + + InputManager + collects; splits shift+wheel to horizontal + + + paint pass + per-tile hit test, before clipping + + + + + + + + flush + combine: first non-null hit wins + + + RenderingContext + handlePossibleComponentMouseEvent + + + component callback + mouseClicked / hover / wheel + + + + + + stereo: only the eye whose viewport contains the cursor combines hits + enter/exit transitions fire mouseEntered / mouseExited + diff --git a/Documentation/GUI components/Focus model.svg b/Documentation/GUI components/Focus model.svg new file mode 100644 index 0000000..a88af62 --- /dev/null +++ b/Documentation/GUI components/Focus model.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + Focus is single-level + + + + WorldNavigationUserInputTracker + default: WASD + arrows fly the camera + (KeyboardFocusStack's home state) + + + GuiComponent (focused) + keys go to the widget + red wireframe border marks focus + + + push: click + + + pop: ESC / middle click + + + pop always returns to camera navigation - never to a previously focused widget + the class is called KeyboardFocusStack for historical reasons; there is no stack + push notifies the old owner with focusLost() and forgets it + diff --git a/Documentation/GUI components/Texture UV picking.svg b/Documentation/GUI components/Texture UV picking.svg new file mode 100644 index 0000000..45ce55b --- /dev/null +++ b/Documentation/GUI components/Texture UV picking.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + A click lands in texture pixels + + + + + + + + textured panel in 3D + + + + + click (x,y) + + + + perspective-correct + UV interpolation + + + + + + + + + texture bitmap + (u,v) + + u and v arrive in primary-texture pixels, NaN when the hit shape is untextured + the same math drives hover tracking and click forwarding into captured app windows + diff --git a/Documentation/GUI components/index.org b/Documentation/GUI components/index.org new file mode 100644 index 0000000..c99b98c --- /dev/null +++ b/Documentation/GUI components/index.org @@ -0,0 +1,166 @@ +: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: + +[[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]] diff --git a/Documentation/Global illumination/index.org b/Documentation/Global illumination/index.org index 9568956..5419b80 100644 --- a/Documentation/Global illumination/index.org +++ b/Documentation/Global illumination/index.org @@ -96,15 +96,56 @@ resolution, never from interpolation. (An earlier bilinear-upscaling 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 @@ -165,12 +206,13 @@ Consequences of the design: 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. @@ -196,6 +238,47 @@ interface that =GlobalIllumination= installs into the 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 @@ -221,6 +304,10 @@ Tuning knobs (system properties): | =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 | diff --git a/Documentation/Octree ray tracing/Cell pool.svg b/Documentation/Octree ray tracing/Cell pool.svg new file mode 100644 index 0000000..e8e977a --- /dev/null +++ b/Documentation/Octree ray tracing/Cell pool.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + One cell = eight parallel int arrays + internal cell: all eight arrays are child pointers; solid leaf: state, color, illumination + + + + 0 + 1 + 2 + 3 + 4 + 5 + + + + + + + + + + + + + + + + + + + + cell1 + cell2 + cell3 + cell4 + cell5 + cell6 + cell7 + cell8 + + + state / child 1 + color / child 2 + illum. / child 3 + child 4 + child 5 + child 6 + child 7 + child 8 + + + + ← index 0 = master cell + allocation = scan for a free slot; exhaustion throws + diff --git a/Documentation/Octree ray tracing/Octree subdivision.svg b/Documentation/Octree ray tracing/Octree subdivision.svg new file mode 100644 index 0000000..2767b6b --- /dev/null +++ b/Documentation/Octree ray tracing/Octree subdivision.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + Recursive 8-way subdivision + a cube splits into 8 octants; only occupied octants split further + + + + + + L0 master + + + + + L1 + + + + + L2 + + 2D analogue (4 quadrants) of the 3D 8-octant split + + + + + master cell [0] + + + + + + + octant 1 + + octant 2 + + ... + level 1: 8 cells + + + + + + sub 1 + + ... + one octant splits again: level 2 + + + only occupied regions subdivide - empty space is never allocated + diff --git a/Documentation/Octree ray tracing/Ray traversal.svg b/Documentation/Octree ray tracing/Ray traversal.svg new file mode 100644 index 0000000..b04cd95 --- /dev/null +++ b/Documentation/Octree ray tracing/Ray traversal.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + Ray traversal: primary rays and shadow rays + + + + + + + + + + + + + + + + + + empty: skipped + empty: skipped + empty: skipped + + + + + hit voxel + + + + camera + + + + primary ray (one per pixel) + + + + light 1 + + lit + + + + light 2 + + + + blocked → shadow + + traversal descends the octree hierarchy - empty regions are skipped without touching individual voxels + diff --git a/Documentation/Octree ray tracing/index.org b/Documentation/Octree ray tracing/index.org new file mode 100644 index 0000000..2f73537 --- /dev/null +++ b/Documentation/Octree ray tracing/index.org @@ -0,0 +1,140 @@ +: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: + +[[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]] diff --git a/Documentation/Rendering loop/index.org b/Documentation/Rendering loop/index.org index d1b5e83..e9bd53c 100644 --- a/Documentation/Rendering loop/index.org +++ b/Documentation/Rendering loop/index.org @@ -42,7 +42,7 @@ Each step has a specific purpose: |-----------+-------+--------+---------| | 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 | @@ -196,6 +196,9 @@ Every shape exists in "world space" — its own position in the 3D world. 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 @@ -230,21 +233,36 @@ else if (z1 > z2) return -1; // z1 is farther -> sort before z2 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 @@ -289,9 +307,15 @@ piece — so cores never idle behind a busy thread. 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 diff --git a/Documentation/SpaceMouse 6DOF/SpaceMouse gestures.svg b/Documentation/SpaceMouse 6DOF/SpaceMouse gestures.svg new file mode 100644 index 0000000..079c7eb --- /dev/null +++ b/Documentation/SpaceMouse 6DOF/SpaceMouse gestures.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + Six gestures, six camera axes + + + + cap gesture + camera + + + push cap right / left + strafe right / left (world X) + + + push cap away / toward you + forward / backward (world Z) + + + press cap down / pull up + down / up (world Y) + + + twist clockwise / counter-clockwise + yaw right / left (2x sensitivity) + + + tilt top away / toward you + look up / down + + + left cap button + brake: kill camera velocity + + + cap deflection = velocity - push harder to fly faster, release to stop instantly + diff --git a/Documentation/SpaceMouse 6DOF/SpaceMouse.png b/Documentation/SpaceMouse 6DOF/SpaceMouse.png new file mode 100644 index 0000000000000000000000000000000000000000..f878e23268899f0f2d25d2c21ee7c9025ebadf2d GIT binary patch literal 113975 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 v-jyGBT& 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 { max-width: 100%;} + +[[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]] diff --git a/Documentation/Stereoscopic rendering/Head tracking.svg b/Documentation/Stereoscopic rendering/Head tracking.svg new file mode 100644 index 0000000..24b96f7 --- /dev/null +++ b/Documentation/Stereoscopic rendering/Head tracking.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + Head tracking: the glasses drive the camera + RayNeo XR glasses IMU over hidraw - auto-starts with ViewPanel, hot-plug at runtime + + + + + RayNeo glasses + gyro + accel, ~500 Hz + + + RayNeoHid + hidraw, 64-byte reports + + + HeadTracker + gyro + gravity fusion + + + HeadLookController + yaw / pitch -> camera + + + + + + + HeadTrackingManager: auto-start with ViewPanel, hot-plug, frozen-stream watchdog + Scroll Lock recenters yaw drift - mouse drag and SpaceMouse compose on top of head look + diff --git a/Documentation/Stereoscopic rendering/Stereo per eye.svg b/Documentation/Stereoscopic rendering/Stereo per eye.svg index 5fc9cfd..af3193e 100644 --- a/Documentation/Stereoscopic rendering/Stereo per eye.svg +++ b/Documentation/Stereoscopic rendering/Stereo per eye.svg @@ -1,4 +1,4 @@ - + @@ -10,36 +10,36 @@ - + - What changes per eye + What changes per eye concern per-eye behavior - + camera translation.x += ±IPD/2 (restored after the pass) - + projection scale = eyeWidth/3; x += stereoViewportOffsetX - + frustum culling built from stereoViewportWidth - narrower FOV per eye - + painting clipped to [renderMinX, renderMaxX) = the eye's half - + mouse picking hits combined only for the eye containing the cursor - + HUD / overlays drawn once, spanning the full frame (zero disparity) diff --git a/Documentation/Stereoscopic rendering/index.org b/Documentation/Stereoscopic rendering/index.org index f1ceee8..261e403 100644 --- a/Documentation/Stereoscopic rendering/index.org +++ b/Documentation/Stereoscopic rendering/index.org @@ -145,6 +145,77 @@ so the viewer can tune comfort at runtime. #+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 diff --git a/Documentation/Subpixel culling/Culling ladder.svg b/Documentation/Subpixel culling/Culling ladder.svg new file mode 100644 index 0000000..840d9ad --- /dev/null +++ b/Documentation/Subpixel culling/Culling ladder.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + The culling ladder: work discarded as early as possible + + + + scene: every shape in the world + + + 1. composite frustum culling - skip invisible subtrees + + + 2. screen-bounds culling - drop off-frame shapes + + + 3. subpixel culling - drop shapes smaller than a pixel + ← THIS PAGE + + + 4. Hi-Z occlusion - drop hidden blocks + + + what remains paints + + + stages 1-3 run inside transform; Hi-Z tests whole mesh blocks against last frame's depth pyramid + diff --git a/Documentation/Subpixel culling/Verdict epoch.svg b/Documentation/Subpixel culling/Verdict epoch.svg new file mode 100644 index 0000000..97b6688 --- /dev/null +++ b/Documentation/Subpixel culling/Verdict epoch.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + The verdict cache: skip setup, not just drawing + + + + + epoch 7: cached skips + + epoch 8: re-evaluate once, + + epoch 9: ... + + + + + camera moved > 25 units + + rotation > ~1.1 degrees + + + + + + + + + frames → + + then cached again + + while the epoch holds, a culled shape returns at the top of transform() - no vertex math at all + the epoch also bumps unconditionally every 30 frames (-Daukio.cull.subpixel.frames) + a culled shape is re-evaluated whenever the camera could have made it visible again + diff --git a/Documentation/Subpixel culling/index.org b/Documentation/Subpixel culling/index.org new file mode 100644 index 0000000..0f3cfa1 --- /dev/null +++ b/Documentation/Subpixel culling/index.org @@ -0,0 +1,115 @@ +: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: + +[[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]] diff --git a/Documentation/index.org b/Documentation/index.org index 78e010e..1cc4461 100644 --- a/Documentation/index.org +++ b/Documentation/index.org @@ -87,8 +87,33 @@ The cameras stay parallel and are offset by a configurable IPD 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: @@ -121,6 +146,78 @@ under minification, all without a mipmap chain. 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 @@ -161,6 +258,19 @@ Also add the repository (the library is not on Maven Central): - 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 @@ -690,6 +800,22 @@ See [[file:Depth%20buffer/][Depth buffer]] for the full treatment. 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==. + +#+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 diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java index 9203394..5dda998 100755 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java @@ -502,7 +502,8 @@ public class ViewPanel extends Canvas { 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; diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java index 851c6c8..81fdd35 100755 --- a/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/Transform.java @@ -7,9 +7,11 @@ package eu.svjatoslav.aukio.e3d.math; 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). * - *

Transformations are applied in order: rotation first, then translation.

+ *

Transformations are applied in order: scale first (about the local + * origin), then rotation, then translation.

* *

Performance optimization: The rotation matrix is cached and only * recomputed when the rotation quaternion changes. This avoids allocating a @@ -43,6 +45,13 @@ public class Transform implements Cloneable { */ 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. @@ -118,13 +127,50 @@ public class Transform implements Cloneable { } /** - * 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. + * + *

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.

+ * + *

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).

+ * + * @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; } /** @@ -166,16 +212,25 @@ public class Transform implements Cloneable { } /** - * 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. * *

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.

* + *

With the default scale of 1.0 the multiply is skipped, so existing + * rigid-motion usage produces bit-identical results.

+ * * @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); } diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java b/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java index 58023c4..1335a01 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/math/TransformStack.java @@ -25,15 +25,16 @@ import eu.svjatoslav.aukio.e3d.geometry.Point3D; * 3. Apply ship's position relative to the world * * - *

Implementation: eager composition. 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.

+ *

Implementation: eager composition. 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.

* *

Contract: composition is a snapshot taken at push time. Mutating * a transform after pushing it has no effect on the stack until it is @@ -101,9 +102,14 @@ public class TransformStack { * Pushes a transform onto the stack, composing it with the current * top-level composite. * - *

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.

+ *

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.

* * @param transform the transform to push (snapshotted at push time) */ @@ -113,18 +119,19 @@ public class TransformStack { 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; @@ -142,15 +149,15 @@ public class TransformStack { 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]; diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java index d5fa783..0e2d921 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java @@ -16,52 +16,95 @@ import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCom 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). * + *

Phased scheduling (default, {@code -De3d.gi.phases=true}): + * the scene is lit in three strict global phases, each ordered by polygon + * distance from the camera, near first:

+ *
    + *
  1. A — centroid direct: 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).
  2. + *
  3. B — centroid multi-bounce: 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.
  4. + *
  5. 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, 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.
  6. + *
+ * + *

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.

+ * *

Two sampling resolutions:

*
    *
  • Lightmapped triangles ({@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.
  • - *
  • Plain solid polygons: per-polygon sampling; the result feeds - * the flat-shading path through {@link GiLightProvider} (shadow tests - * + indirect add). Polygons stay single-colored.
  • + * 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. + *
  • Plain solid polygons: 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.
  • *
* - *

Estimator: 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.

+ *

Estimator: 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.

* - *

Render-side cost: 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.

+ *

Render-side cost: 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.

* *

Limitations: diffuse light only; polygon vertices are used in - * composite-local space, so scenes combining composites with non-identity - * transforms are traced incorrectly.

+ * composite-local space, so scenes combining composites with + * non-identity transforms are traced incorrectly.

* *

Usage:

*
{@code
@@ -102,6 +145,44 @@ public class GlobalIllumination implements GiLightProvider {
     /** 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
@@ -127,18 +208,33 @@ public class GlobalIllumination implements GiLightProvider {
             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 cameraPosition;
 
     private final List threads = new ArrayList<>();
     private volatile boolean running;
@@ -155,22 +251,53 @@ public class GlobalIllumination implements GiLightProvider {
     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 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 entries;
         TriangleBvh bvh;
         List lights;
         IdentityHashMap 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 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 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
@@ -187,6 +314,7 @@ public class GlobalIllumination implements GiLightProvider {
 
     /**
      * 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
@@ -195,9 +323,27 @@ public class GlobalIllumination implements GiLightProvider {
     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 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. */
@@ -235,26 +381,31 @@ public class GlobalIllumination implements GiLightProvider {
 
     /**
      * 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;
     }
 
     // ------------------------------------------------------------------
@@ -301,46 +452,50 @@ public class GlobalIllumination implements GiLightProvider {
             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;
@@ -355,119 +510,523 @@ public class GlobalIllumination implements GiLightProvider {
         }
     }
 
-    /** 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,
@@ -564,136 +1123,229 @@ public class GlobalIllumination implements GiLightProvider {
         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");
@@ -766,9 +1418,22 @@ public class GlobalIllumination implements GiLightProvider {
         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 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());
@@ -792,25 +1457,66 @@ public class GlobalIllumination implements GiLightProvider {
         snap.ambientG = ambient.g;
         snap.ambientB = ambient.b;
 
-        // Flattened work list: one item per valid lightmap texel,
-        // one per plain polygon.
-        final List 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 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();
@@ -818,11 +1524,17 @@ public class GlobalIllumination implements GiLightProvider {
         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() {
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java
index 4acf19e..dffa1a8 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java
@@ -105,6 +105,52 @@ public class Lightmap {
     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;
@@ -159,6 +205,8 @@ public class Lightmap {
         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;
@@ -254,6 +302,108 @@ public class Lightmap {
         }
     }
 
+    /**
+     * 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.
      *
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java
index 5e099ae..d7ffcb6 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java
@@ -34,6 +34,19 @@ public class TriangleBvh {
         /** 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;
         }
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
index 3a3ca1d..e8d315d 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
@@ -93,6 +93,17 @@ public class SolidPolygon extends AbstractCoordinateShape {
      * 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.
      */
@@ -697,17 +708,6 @@ public class SolidPolygon extends AbstractCoordinateShape {
             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());
@@ -719,13 +719,29 @@ public class SolidPolygon extends AbstractCoordinateShape {
             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) {
@@ -818,6 +834,16 @@ public class SolidPolygon extends AbstractCoordinateShape {
             );
             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
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java
index 9aba6d4..b03e1e7 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java
@@ -126,7 +126,7 @@ public class LightmappedCompositeShape extends AbstractCompositeShape {
     private void wrapLightmapped(final SolidPolygon polygon, final List out) {
         final int vertexCount = polygon.getVertexCount();
         if (vertexCount == 3) {
-            out.add(wrapTriangle(polygon));
+            emitWrapped(polygon, out);
             return;
         }
         // Fan: anchor vertex 0, then consecutive pairs
@@ -140,15 +140,46 @@ public class LightmappedCompositeShape extends AbstractCompositeShape {
             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 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;
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoader.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoader.java
new file mode 100644
index 0000000..2da4ca2
--- /dev/null
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoader.java
@@ -0,0 +1,341 @@
+/*
+ * 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).
+ *
+ * 

Supported OBJ statements:

+ *
    + *
  • {@code v x y z} — vertex positions (extra components ignored)
  • + *
  • {@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.
  • + *
  • {@code usemtl name} — selects the material for following faces
  • + *
  • {@code mtllib file [file ...]} — material library, resolved relative + * to the OBJ file (or via the caller-provided resolver)
  • + *
  • {@code o}, {@code g}, {@code s}, {@code vt}, {@code vn}, {@code #} — + * parsed past and ignored
  • + *
+ * + *

Supported MTL statements: {@code newmtl}, {@code Kd} (diffuse color), + * {@code d} (opacity) and {@code Tr} (transparency). Everything else is + * ignored.

+ * + *

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.

+ * + *

Limitation: faces are rendered as convex polygons (fan triangulation); + * non-convex faces may render with overlapping triangles.

+ * + *

Usage:

+ *
{@code
+ * ObjModel model = ObjLoader.load(Path.of("city1.obj"));
+ * model.setShadingEnabled(true);
+ * shapes.addShape(model);
+ * }
+ */ +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 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 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 resourceResolver) + throws IOException { + final List vertices = new ArrayList<>(); + final Map materials = new HashMap<>(); + final Set usedMaterials = new HashSet<>(); + final List 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 resourceResolver, + final Map 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 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); + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjMaterial.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjMaterial.java new file mode 100644 index 0000000..3a00497 --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjMaterial.java @@ -0,0 +1,45 @@ +/* + * 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. + * + *

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.

+ */ +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; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjModel.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjModel.java new file mode 100644 index 0000000..8e3037e --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjModel.java @@ -0,0 +1,54 @@ +/* + * 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). + * + *

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.

+ * + * @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; + } +} diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/package-info.java new file mode 100644 index 0000000..cfd4efe --- /dev/null +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/package-info.java @@ -0,0 +1,11 @@ +/* + * 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; diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java index ed5275b..eb4de15 100644 --- a/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java +++ b/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java @@ -136,4 +136,208 @@ public class TransformStackTest { 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); + } + } } diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoaderTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoaderTest.java new file mode 100644 index 0000000..eb677ea --- /dev/null +++ b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoaderTest.java @@ -0,0 +1,241 @@ +/* + * 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 mtlFiles() { + final Map files = new HashMap<>(); + files.put("test.mtl", MTL); + return files; + } + + private static ObjModel load(final String obj, final Map 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 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 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); + } +} diff --git a/udev/99-rayneo-glasses.rules b/udev/99-rayneo-glasses.rules new file mode 100644 index 0000000..8e716cf --- /dev/null +++ b/udev/99-rayneo-glasses.rules @@ -0,0 +1 @@ +ACTION=="add|change", SUBSYSTEM=="hidraw", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1bbb", ATTRS{idProduct}=="af50", MODE="0666" diff --git a/udev/99-spacenavigator.rules b/udev/99-spacenavigator.rules new file mode 100644 index 0000000..524cfde --- /dev/null +++ b/udev/99-spacenavigator.rules @@ -0,0 +1 @@ +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c626", MODE="0666" -- 2.20.1