| ~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/SpaceMouse 6DOF/index.org~ | 3Dconnexion SpaceNavigator/SpaceMouse Wireless: 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 |
<rect x="136" y="45" width="104" height="64" rx="3" fill="rgba(255,102,0,0.15)" stroke="#FF6600" stroke-width="1.5"/>
<text x="188" y="66" fill="#FF8833" font-size="10" font-family="monospace" text-anchor="middle" font-weight="bold">GI workers</text>
- <text x="188" y="80" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">Monte Carlo</text>
- <text x="188" y="92" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">sweeps</text>
+ <text x="188" y="80" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">bounce</text>
+ <text x="188" y="92" fill="#aaa" font-size="9" font-family="monospace" text-anchor="middle">generations</text>
<rect x="262" y="45" width="104" height="64" rx="3" fill="rgba(48,160,80,0.15)" stroke="#30a050" stroke-width="1.5"/>
<text x="314" y="66" fill="#39FF14" font-size="10" font-family="monospace" text-anchor="middle" font-weight="bold">lightmaps</text>
<!-- feedback arrow: lightmaps feed the next sweep's bounce targets -->
<path d="M 314 109 L 314 135 L 188 135 L 188 111" fill="none" stroke="#30a050" stroke-width="1.2" stroke-dasharray="4 3" marker-end="url(#arrowhead)"/>
- <text x="251" y="147" fill="#30a050" font-size="9" font-family="monospace" text-anchor="middle">bounce reads last sweep's estimate</text>
+ <text x="251" y="147" fill="#30a050" font-size="9" font-family="monospace" text-anchor="middle">generation k reads generation k-1</text>
<text x="562" y="135" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">zero ray casting</text>
<text x="562" y="147" fill="#666" font-size="9" font-family="monospace" text-anchor="middle">on render threads</text>
#+attr_latex: :width 640px
[[file:gi-flat.png]]
-*Aukio 3D* can optionally compute *global illumination* progressively
-on background CPU threads: shadows appear, light pools under lamps
-with smooth falloff, and colored light *bleeds* — a red sofa tints the
-floor next to it red. All of it converges gradually over the first
-seconds of a scene, then idles.
+*Aukio 3D* can optionally compute *global illumination* on background
+CPU threads: shadows appear, light pools under lamps with smooth
+falloff, and colored light *bleeds* — a red sofa tints the floor next
+to it red. The lighting is baked generation by generation over the
+first seconds of a scene, then the workers idle and the result stays
+on screen.
-Same camera, same house: flat shading (top) versus converged GI
-(below). Note the soft shadow of the partition wall, the lamp glow on
-the ceiling, and the subtle color variation across the floor.
+Same camera, same house: flat shading (top) versus baked GI (below).
+Note the soft shadow of the partition wall, the lamp glow on the
+ceiling, and the subtle color variation across the floor.
#+attr_html: :class responsive-img
#+attr_latex: :width 640px
lightmapped triangle is an ordinary texture lookup, exactly as fast as
any textured polygon.
-All the expensive work happens on dedicated low-priority worker threads
-that continuously refine per-surface lighting values. Whenever the
-values have improved enough, the workers regenerate each triangle's
-*composite texture* (baseColor x total lighting) into a back buffer
-and swap it in atomically — painters never see a half-updated texture.
+All the expensive work happens on dedicated low-priority worker
+threads that bake per-surface lighting values. At a fixed cadence the
+workers regenerate each triangle's *composite texture* (baseColor x
+total lighting) into a back buffer and swap it in atomically —
+painters never see a half-updated texture.
#+INCLUDE: "GI pipeline.svg" export html
texture is flood-filled from valid neighbors so that nearest sampling
near the hypotenuse never picks up garbage.
-Each texel stores two things, both written only by GI threads:
+During the bake each texel carries four kinds of state, written only
+by GI threads:
+
+- *Indirect total* (RGB floats) — the accumulated bounced light, the
+ sum of all completed generations. This is what the composite texture
+ displays.
+- *Previous-generation delta* (RGB floats) — the last completed
+ generation's contribution: the gather source for the generation
+ currently being traced.
+- *Current-generation accumulator* (RGB floats) — the running mean of
+ the generation in progress, shown as a live preview.
+- *Per-light visibility bytes* — whether the shadow ray from this
+ texel reached each lamp (written in generation 1; direct light is
+ always derived from these, never stored separately).
+
+All of it lives on the triangle's
+[[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.html][Lightmap]]
+as flat arrays indexed by texel (=texel = j * width + i=):
-- *Indirect irradiance* (RGB floats) — the accumulated bounced light.
-- *Per-light visibility bits* — whether the last shadow ray from this
- texel reached each lamp (cached so later queries cost nothing).
+#+BEGIN_SRC java
+/** Accumulated indirect total and the two generation buffers
+ (pre-albedo light units), width * height entries each. All arrays
+ are null before the bake reaches this lightmap and after the bake
+ completes — a finished lightmap keeps only its final texture. */
+public volatile float[] indirectR, indirectG, indirectB; // total
+public volatile float[] deltaPrevR, deltaPrevG, deltaPrevB; // gen k-1
+public volatile float[] deltaCurR, deltaCurG, deltaCurB; // gen k
+
+/** Per-texel per-light visibility: texelCount * lightCount bytes,
+ indexed [texel * lightCount + lampIndex]. One byte per pair:
+ 0 = unknown, 1 = shadow ray reached the lamp, 2 = blocked. */
+public byte[] lightVisibility;
+public int lightCount;
+
+/** Bake progress: 0 = nothing, 1 = direct done, k = k generations. */
+public volatile int generation;
+#+END_SRC
+
+Two footnotes for the precise reader: visibility is a whole byte per
+texel-lamp pair, not a packed bitfield (the plain-polygon path is the
+one using literal bits — a =visibleBits= / =knownBits= pair of =long=
+masks, one bit per lamp), and at most the first 16 lamps are tracked
+per scene.
+
+*The arrays are bake scaffolding, not permanent state.* They are
+allocated lazily when the bake reaches a lightmap — a lightmapped
+scene that never enables GI pays only the two composite textures per
+triangle — and they are freed scene-wide the moment the bake
+completes. What remains per triangle is exactly the two
+double-buffered textures (one shown, one spare). A scene or light
+change restarts the bake and re-allocates them.
Resolution is set in world units per texel
-(=LightmappedCompositeShape.setLightmapUnitsPerTexel()=, default 12): a 100-unit
-wall cell gets an 8x8 lightmap. Halving the units quadruples the
-tracing work.
+(=LightmappedCompositeShape.setLightmapUnitsPerTexel()=, default 12):
+a 100-unit wall cell gets an 8x8 lightmap. Halving the units
+quadruples the tracing work.
*What you trace is what you see:* the composite texture the painter
samples is the lightmap itself, at native texel resolution — there is
no upscaling step. Shadow-edge smoothness comes from tracing at finer
-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.)
+resolution, never from interpolation.
+
+* Bounce generations
+:PROPERTIES:
+:CUSTOM_ID: generations
+:END:
+
+The scene is lit in strict global generations, each walking all
+entries near-to-far from the camera, with a barrier between
+generations: no texel starts generation k+1 before every texel has
+finished generation k.
-* Phased scheduling: light floods near-to-far
+** Generation 1 — direct light
:PROPERTIES:
-:CUSTOM_ID: phased-scheduling
+:CUSTOM_ID: gen-1-direct
: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
+Every lightmap texel casts one *shadow ray* toward each lamp, nudged
+0.5 world units off the surface along the normal so the ray does not
+self-intersect. The ray is an any-hit query against the BVH: if any
+triangle intersects the segment between the texel and the lamp, the
+lamp is occluded and this texel lies in its shadow. The outcome is
+cached as the texel's visibility bytes. Lamps behind the surface are
+rejected outright, without spending a ray.
+
+Direct light is never stored as a value: whenever it is needed (by
+the compositor, or by generation-2 bounce rays), it is recomputed from
+the cached visibility bytes as ambient + the usual
+cosine x attenuation x intensity sum over visible lamps. That keeps
+the direct term exactly consistent with the shadow cache at all
+times.
+
+Plain polygons and mesh-block triangles get the same evaluation once
+at their centroid — that is their final resolution; they skip the
+lightmap machinery entirely.
+
+** Generation k >= 2 — one more bounce
:PROPERTIES:
-:CUSTOM_ID: one-sample
+:CUSTOM_ID: gen-k-bounce
:END:
-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
- tested at once, so direct light and hard shadows appear after a
- single sweep instead of trickling in lamp by lamp; later visits
- re-test one lamp at a time, round-robin.
-2. *Bounce ray* in a random cosine-weighted direction around the
- normal. Wherever it lands (point Q), the sample reads Q's *direct*
- lighting — using Q's cached shadow bits, no new shadow rays — plus
- Q's *current indirect estimate*, and blends the sum into the texel's
- own indirect value.
+Every texel casts =e3d.gi.samples= (default 8) *bounce rays* in random
+cosine-weighted directions around the normal, and averages them as a
+plain running mean. What a ray reads where it lands (point Q) depends
+on the generation:
+
+- *Generation 2* reads Q's *direct* light (from Q's cached visibility
+ — no new shadow rays). Result: light that has bounced exactly once.
+- *Generation k >= 3* reads only Q's *generation-(k-1) delta*. Result:
+ light that has bounced exactly k-1 times.
+
+The strict barrier is what makes this well-defined: generation k only
+ever reads completed generation-(k-1) output, never a half-written
+current value. And because the sampled field is frozen for the whole
+generation, the samples are independent — a plain average is the
+right estimator, with no exponential moving average and no blend
+factors to tune.
#+INCLUDE: "Bounce estimator.svg" export html
-Reading Q's current indirect estimate instead of recursing is what
-makes bounce light propagate: sweep 1 learns "Q is directly lit",
-sweep 2 learns "P sees a lit Q", sweep 3 learns "R sees a lit P"...
-Light ripples one surface deeper with every sweep, with no recursion
-limit and no exponential ray explosion. The =1/pi= diffuse gain keeps
-the feedback loop from diverging: without it the indirect term
-amplifies itself and the scene saturates to white.
+At the barrier the finished delta folds into the texel's accumulated
+total (what the composite texture displays), the delta buffers swap,
+and the next generation begins. The =1/pi= diffuse gain keeps each
+generation smaller than the last: the loop gain is below one, so
+bounce light fades geometrically with depth.
-* Progressive convergence
+** Termination and teardown
:PROPERTIES:
-:CUSTOM_ID: convergence
+:CUSTOM_ID: termination
:END:
-Monte Carlo samples are noisy, so blending happens at *two nested
-levels*, both exponential moving averages:
-
-1. *Inner, per sample*: each texel blends every new bounce-ray result
- into its indirect estimate with a constant weight (=alpha = 0.15=,
- mode =fixed=). Every ray hit stays equally intensive forever — an
- unlit area fades to darkness at the same rate a lit area brightens.
- (The old =adaptive= mode, which decays alpha with sample count, is
- still available; see the knobs below.)
-2. *Outer, per composite update*: the value that reaches the screen is
- a second EMA over the COMPLETE sum =ambient + direct + indirect=.
- The texture can only move =e3d.gi.compositeAlpha= (default 0.2) of
- the remaining distance per 500 ms update — so direct light, shadows
- and bounce light all glide in together over a few seconds, and no
- single-frame jump is possible by construction.
+The bake ends when a generation's mean absolute per-texel delta falls
+below =e3d.gi.deltaThreshold= (default 0.5 light units — later
+generations can only be smaller, so they cannot move the picture) or
+when =e3d.gi.generations= generations have run (default 6 = direct +
+up to 5 bounce generations). Then every lightmap composites one final
+time and all per-texel arrays are freed. The workers idle at a low
+cadence, watching for scene changes.
+
+The generation cap is an honest trade: a hard stop at 6 generations
+discards the remaining series tail — single-digit percent of the
+indirect energy in a typical interior, but up to a fifth in a bright
+white room. Raise the knob for such scenes; the cost is one more
+full-scene pass per extra generation.
+
+* What you see while it bakes
+:PROPERTIES:
+:CUSTOM_ID: display
+:END:
The world starts at a *uniform medium irradiance*
(=e3d.gi.initialIrradiance=, default 128): the scene is visible from
-the very first frame, then lit areas brighten and unlit areas sink to
-darkness as the workers sweep — lights and shadows gradually become
-distinguished instead of the old pitch-black start with a sudden flash
-once the first sweep landed:
+the very first frame. Each lightmap then flips to its traced direct
+lighting when generation 1 reaches it — near-to-far, so the change
+sweeps across the view as a wave — and bounce light deepens generation
+by generation after that.
+
+During the bake the composite textures regenerate at most every
+500 ms, showing the accumulated total plus the in-progress
+generation's mean. There is deliberately no display smoothing: within
+a generation the values only get less noisy, and at the barrier they
+only grow toward the final solution, so steps are small and always in
+the right direction.
-#+attr_html: :class responsive-img
-#+attr_latex: :width 640px
-[[file:gi-start.png]]
-
-Consequences of the design:
-
-- *Hysteresis is free*: when a lamp moves or geometry changes, old
- 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 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.
-- *Composite cadence*: textures regenerate at most every 500 ms — one
- atomic swap per triangle, invisible to painters.
+- *Scene changes*: any geometry or light change rebuilds the snapshot
+ and restarts the bake from generation 1. The old textures stay on
+ screen until the new direct pass lands — a visible step, not a
+ gradual fade (the price of freeing all solver state between bakes).
* Plain polygons get GI too
:PROPERTIES:
bounced-light estimate into the flat-shaded color.
Both are called from parallel render-pool threads, so they only read
-volatile caches — never trace.
+volatile caches — never trace. Unlike lightmap texel arrays, these
+few per-polygon floats are permanent: the renderer reads them every
+frame.
* Mesh blocks get GI too
:PROPERTIES:
On the tracing side a mesh-block triangle is a =TriangleBvh.Entry=
whose =polygon= is null; the entry instead carries the block reference
-and the triangle index. Progressive state lives in the same per-state
-map, keyed by the entry rather than the polygon. Phase A writes the
-triangle's direct light + shadow bits straight into the block's array;
-phase B adds the bounce estimate on top. Blocks skip the texel phase
-entirely — there is no lightmap to update. Albedo for bounce coloring
-comes from each block's average texture color, so warm walls warm up
-the shade near them.
+and the triangle index, and its progressive state lives in the same
+per-state map, keyed by the entry rather than the polygon. Generation
+1 writes the triangle's direct light into the block's array; each
+generation barrier adds the latest bounce delta on top. Albedo for
+bounce coloring comes from each block's average texture color, so warm
+walls warm up the shade near them.
Because streaming worlds bump the render-list version on every loaded
cell, snapshot rebuilds are single-flighted (one worker builds, the
exactly one surface, so nothing z-fights and no holes appear even
when the source model's winding is inconsistent.
+* Enabling GI
+:PROPERTIES:
+:CUSTOM_ID: enabling
+:END:
+
+#+BEGIN_SRC java
+// Per-texel lightmaps on composite geometry (the House demo setup):
+LightmappedCompositeShape house = new LightmappedCompositeShape();
+house.setLightmappingEnabled(true);
+house.setLightmapUnitsPerTexel(3.0); // fine texels: quality from traced rays
+
+// Start the workers (2 threads by default; more bake faster):
+viewPanel.enableGlobalIllumination(4);
+#+END_SRC
+
+Tuning knobs (system properties):
+
+|| Property | Default | Effect ||
+||---------------------------+---------+-----------------------------------------||
+|| =e3d.gi.generations= | 6 | total generations: 1 = direct, each more = +1 bounce ||
+|| =e3d.gi.samples= | 8 | bounce rays per texel per generation, averaged ||
+|| =e3d.gi.deltaThreshold= | 0.5 | early stop: generation mean delta (light units) ||
+|| =e3d.gi.initialIrradiance=| 128 | uniform medium start (0..255 light units) ||
+|| =e3d.gi.despeckle= | true | neighbor-smoothing of indirect at composite time ||
+|| =e3d.gi.minRebuildMs= | 1000 | snapshot rebuild rate limit (streaming worlds) ||
+|| =e3d.gi.debug= | false | generation statistics to stdout ||
+|| =e3d.gi.dumpLightmaps= | (unset) | dump composite lightmaps as PNGs to the given dir ||
+
* Coordinate spaces: lights are world, geometry is local
:PROPERTIES:
:CUSTOM_ID: coordinate-spaces
Animated transforms above a lightmapped composite were already
unsupported; this is the same rule, one level up.
-* Enabling GI
-:PROPERTIES:
-:CUSTOM_ID: enabling
-:END:
-
-#+BEGIN_SRC java
-// Per-texel lightmaps on composite geometry (the House demo setup):
-LightmappedCompositeShape house = new LightmappedCompositeShape();
-house.setLightmappingEnabled(true);
-house.setLightmapUnitsPerTexel(3.0); // fine texels: quality from traced rays
-
-// Start the workers (2 threads by default; more converge faster):
-viewPanel.enableGlobalIllumination(4);
-#+END_SRC
-
-Tuning knobs (system properties):
-
-| Property | Default | Effect |
-|---------------------------+---------+-----------------------------------------|
-| =e3d.gi.alphaMode= | fixed | =adaptive= decays the inner EMA alpha with sample count |
-| =e3d.gi.alphaFloor= | 0.08 | adaptive-mode floor; higher adapts faster but noisier |
-| =e3d.gi.compositeAlpha= | 0.2 | outer EMA: fade speed of the on-screen estimate per 500 ms update |
-| =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 |
-
* Limitations
:PROPERTIES:
:CUSTOM_ID: limitations
- *Diffuse light only* — no specular bounce, no caustics.
- Polygon vertices are traced in composite-local space; scenes that put
non-identity transforms on composites are traced incorrectly.
-- The bounce estimate is one ray deep per sample — correctness comes
- from sweep-over-sweep propagation, so deeply indirect corners take
- several sweeps to brighten.
+- The generation cap truncates the bounce series: with the default 6
+ generations, bright interiors lose up to ~20% of the theoretical
+ indirect energy (less in typical rooms). Raise
+ =e3d.gi.generations= to recover it.
+- Any scene or light change restarts the whole bake from generation 1
+ (the old result stays on screen until the new direct pass lands).
+ There is no incremental update for a moved lamp.
- Mesh-block triangles shade per-triangle from the winding normal, so
two-sided (culling-off) foliage shows the same light from both sides.
- Rays that miss all geometry return no radiance; outdoors the sky
:CUSTOM_ID: related-classes
:END:
-| Class | Purpose |
-|----------------------+---------------------------------------------------------------|
-| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.html][GlobalIllumination]] | Progressive tracer: sweeps, EMA convergence, composite swaps |
-| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.html][Lightmap]] | Per-triangle texel state + double-buffered composite textures |
-| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.html][LightmappedTriangle]] | Textured triangle whose texture is the GI composite |
-| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.html][TriangleBvh]] | BVH over world triangles: nearest-hit and any-hit ray queries (Möller–Trumbore) |
-| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.html][GiLightProvider]] | Cache-read interface feeding the flat-shading path |
-| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.html][LightmappedCompositeShape]] | Wraps polygons into lightmapped triangles |
+|| Class | Purpose ||
+||----------------------+---------------------------------------------------------------||
+|| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.html][GlobalIllumination]] | Generation baker: barrier scheduling, rollovers, composites ||
+|| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.html][Lightmap]] | Per-triangle texel state + double-buffered composite textures ||
+|| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/LightmappedTriangle.html][LightmappedTriangle]] | Textured triangle whose texture is the GI composite ||
+|| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.html][TriangleBvh]] | BVH over world triangles: nearest-hit and any-hit ray queries (Möller–Trumbore) ||
+|| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/lighting/GiLightProvider.html][GiLightProvider]] | Cache-read interface feeding the flat-shading path ||
+|| [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.html][LightmappedCompositeShape]] | Wraps polygons into lightmapped triangles ||
:END:
Keyboard and mouse move the camera one axis at a time. A 3Dconnexion
-*SpaceNavigator* — a pressure-sensitive 6DOF cap — moves it on all
+6DOF mouse — a pressure-sensitive 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=
+when a supported device is connected — a *SpaceNavigator* over USB or
+a *SpaceMouse Wireless* over Bluetooth — =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=.
+demo flies the camera with zero setup. Unplugging (or Bluetooth
+disconnect) returns control to mouse and keyboard. Disable with
+=-De3d.spacemouse=false=.
The package lives in =eu.svjatoslav.aukio.e3d.gui.spacemouse=.
: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
+Device access needs a udev rule that makes the device'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
+# 3Dconnexion SpaceNavigator (USB)
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c626", MODE="0666"
+# 3Dconnexion SpaceMouse Wireless (Bluetooth)
+SUBSYSTEM=="hidraw", KERNELS=="0005:256F:C63A.*", MODE="0666"
#+END_SRC
-Install it from the repository root and replug the device:
+The Bluetooth variant enumerates through =/dev/uhid=, so its parent
+chain carries no =idVendor= attribute and udev does not import
+=HID_ID= into the hidraw node either — the rule matches the parent
+hid device's kernel name (=0005:256F:C63A.*=) instead.
+
+Install it from the repository root and reconnect the device:
#+BEGIN_SRC sh
sudo install -m 644 udev/99-spacenavigator.rules /etc/udev/rules.d/
is deflected — so there is no liveness watchdog; an unplug is noticed
on the next failed read.
+* Wire protocols
+:PROPERTIES:
+:CUSTOM_ID: wire-protocols
+:END:
+
+Both models report the same six axes with the same sign conventions
+and ±350 full deflection, but the report layout differs:
+
+| Model | Motion reports | Extra |
+|--------------------------+--------------------------------------------------+--------------|
+| SpaceNavigator (USB) | 7-byte reports: id 1 = translation, id 2 = rotation | — |
+| SpaceMouse Wireless (BT) | one 13-byte report, id 1 = all six axes | id 0x17 = battery charge + charging flag |
+
+Buttons arrive as report id 3 on both (bit0 = left, bit1 = right).
+The Bluetooth battery level is exposed as
+=SpaceNavigatorHid.getBatteryPercent()= / =isCharging()= (-1 when the
+device has not reported it — wired models never do).
+
* 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 |
+| Class | Role in SpaceMouse support |
+|------------------------+------------------------------------------------------------------|
+| =SpaceMouseManager= | auto-start with ViewPanel, hot-plug scanning |
+| =SpaceNavigatorHid= | hidraw transport for all supported models (=Model= enum) |
+| =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
[[file:SpaceMouse 6DOF/SpaceMouse.png]]
-A 3Dconnexion SpaceNavigator flies the camera with its
+A 3Dconnexion SpaceMouse (SpaceNavigator over USB or SpaceMouse
+Wireless over Bluetooth) 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
+tilt and twist to look. Connecting it auto-starts camera control in
every application (hot-plug at runtime), composing freely with head
tracking and mouse drag.
/**
* Optional input-device hot-plug for a {@link ViewPanel}: starts the
- * RayNeo head-tracking manager and the SpaceNavigator 6DOF-mouse manager
+ * RayNeo head-tracking manager and the 3Dconnexion 6DOF-mouse manager
* (each auto-detects its device even when plugged in after startup), and
* exposes the currently connected device, if any.
*
/** Head tracker hot-plug manager, unless disabled via e3d.headtrack=false. */
private HeadTrackingManager headTrackingManager;
- /** SpaceNavigator hot-plug manager, unless disabled via e3d.spacemouse=false. */
+ /** SpaceMouse hot-plug manager, unless disabled via e3d.spacemouse=false. */
private SpaceMouseManager spaceMouseManager;
/**
}
/**
- * Returns the active SpaceNavigator device, or null when no 6DOF
+ * Returns the active SpaceMouse device, or null when no 6DOF
* mouse is currently connected.
*/
SpaceNavigatorHid getSpaceMouse() {
import eu.svjatoslav.aukio.e3d.diag.EngineConfig;
import eu.svjatoslav.aukio.e3d.diag.Telemetry;
import eu.svjatoslav.aukio.e3d.geometry.Point3D;
-import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadLookController;
import eu.svjatoslav.aukio.e3d.gui.headtrack.HeadTracker;
-import eu.svjatoslav.aukio.e3d.gui.headtrack.RayNeoHid;
import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceNavigatorHid;
import eu.svjatoslav.aukio.e3d.gui.humaninput.InputManager;
import eu.svjatoslav.aukio.e3d.gui.humaninput.KeyboardFocusStack;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
/**
}
/**
- * Returns the active SpaceNavigator device, or null when no 6DOF
+ * Returns the active SpaceMouse device, or null when no 6DOF
* mouse is currently connected.
*/
public SpaceNavigatorHid getSpaceMouse() {
import eu.svjatoslav.aukio.e3d.geometry.Point3D;
/**
- * Applies SpaceNavigator 6DOF input to the camera, every frame:
+ * Applies 3Dconnexion 6DOF input to the camera, every frame (any
+ * model supported by {@link SpaceNavigatorHid} — they share raw axis
+ * conventions):
*
* <ul>
* <li>push/pull/slide the cap → camera moves DIRECTLY, proportional
import eu.svjatoslav.aukio.e3d.gui.ViewPanel;
/**
- * Hot-plug manager for the SpaceNavigator 6DOF mouse. Polls for the
- * device every two seconds:
+ * Hot-plug manager for 3Dconnexion 6DOF mice (SpaceNavigator over USB,
+ * SpaceMouse Wireless over Bluetooth — see
+ * {@link SpaceNavigatorHid.Model}). Polls for the device every two
+ * seconds:
*
* <ul>
* <li>plugged in → open the HID pipe, attach a
thread.start();
}
- /** Active device, or null when no SpaceNavigator is connected. */
+ /** Active device, or null when no supported SpaceMouse is connected. */
public SpaceNavigatorHid getDevice() {
return device;
}
- /** Active controller, or null when no SpaceNavigator is connected. */
+ /** Active controller, or null when no supported SpaceMouse is connected. */
public SpaceMouseController getController() {
return controller;
}
controller = new SpaceMouseController(opened, viewPanel);
viewPanel.addFrameListener(controller);
failureReported = false;
- System.out.println("spacemouse: SpaceNavigator detected — "
- + "cap moves the camera, left button brakes");
+ System.out.println("spacemouse: " + opened.getModel()
+ + " detected — cap moves the camera, "
+ + "left button brakes");
} catch (final Exception e) {
device = null;
if (!failureReported) {
import java.io.File;
/**
- * HID transport for the 3Dconnexion SpaceNavigator 6DOF mouse
- * (USB 046D:C626), read via the Linux hidraw interface using JNA —
- * same approach as the RayNeo glasses transport.
+ * HID transport for 3Dconnexion 6DOF mice, read via the Linux hidraw
+ * interface using JNA — same approach as the RayNeo glasses transport.
+ * Supported models are listed in {@link Model}; all share the same
+ * raw axis sign conventions and ±350 full deflection, so
+ * {@link SpaceMouseController} drives them identically.
*
- * <p>Protocol (from libspnav/spacenavd documentation): the device
- * sends 7-byte input reports, ONLY while the cap is deflected or a
- * button changes — at rest the pipe is silent (so unlike the XR
- * glasses there is no stream watchdog; silence is normal):</p>
+ * <p>Protocol, SpaceNavigator USB (from libspnav/spacenavd
+ * documentation): 7-byte input reports, sent ONLY while the cap is
+ * deflected or a button changes — at rest the pipe is silent (so
+ * unlike the XR glasses there is no stream watchdog; silence is
+ * normal):</p>
*
* <ul>
* <li>report[0] = 1 — translation; int16 LE at [1,2]=X, [3,4]=Y,
* <li>report[0] = 3 — buttons; report[1] bit0 = left, bit1 = right</li>
* </ul>
*
- * <p>Raw axis sign conventions (SpaceNavigator hardware):</p>
+ * <p>Protocol, SpaceMouse Wireless over Bluetooth (from the device's
+ * own HID report descriptor): a single combined motion report plus
+ * separate button and battery reports:</p>
+ *
+ * <ul>
+ * <li>report[0] = 1 — all six axes at once; int16 LE at [1,2]=X,
+ * [3,4]=Y, [5,6]=Z, [7,8]=RX, [9,10]=RY, [11,12]=RZ
+ * (13 bytes total)</li>
+ * <li>report[0] = 3 — buttons; report[1] bit0 = left, bit1 = right
+ * (3 bytes total)</li>
+ * <li>report[0] = 0x17 — battery; report[1] = charge 0..100,
+ * report[2] bit0 = charging</li>
+ * </ul>
+ *
+ * <p>Raw axis sign conventions (identical on both models):</p>
* <ul>
* <li>TX: push cap right → positive</li>
* <li>TY: pull cap toward you → positive (live-verified)</li>
* <li>RZ: twist cap clockwise (seen from above) → positive</li>
* </ul>
*
- * <p>Detection scans /sys/class/hidraw for HID_ID
- * 0003:0000046D:0000C626 — the hidraw node changes on replug, so never
+ * <p>Detection scans /sys/class/hidraw for the HID_ID of any known
+ * model — the hidraw node changes on replug/reconnect, so never
* cache the path. Unplug is detected by a read error (read returns
* <0).</p>
*/
public final class SpaceNavigatorHid {
- private static final String HID_ID = "0003:0000046D:0000C626";
+ /**
+ * A supported 3Dconnexion device. {@code hidId} is matched against
+ * HID_ID in /sys/class/hidraw/<N>/device/uevent (bus prefix
+ * 0003 = USB, 0005 = Bluetooth).
+ */
+ public enum Model {
+ SPACENAVIGATOR_USB("0003:0000046D:0000C626",
+ "SpaceNavigator (USB)"),
+ SPACEMOUSE_WIRELESS_BT("0005:0000256F:0000C63A",
+ "SpaceMouse Wireless (Bluetooth)");
+
+ private final String hidId;
+ private final String displayName;
+
+ Model(final String hidId, final String displayName) {
+ this.hidId = hidId;
+ this.displayName = displayName;
+ }
+
+ /** First model whose HID_ID appears in the uevent text, else null. */
+ static Model match(final String ueventContent) {
+ for (final Model model : values())
+ if (ueventContent.contains(model.hidId))
+ return model;
+ return null;
+ }
+
+ @Override
+ public String toString() {
+ return displayName;
+ }
+ }
/** Full deflection of any axis, per the HID descriptor. */
public static final double FULL_DEFLECTION = 350.0;
}
private final File deviceNode;
+ private final Model model;
private final Memory readBuffer = new Memory(64);
private int fd = -1;
/** Latest axis state, in raw device units (±350). */
private volatile int tx, ty, tz, rx, ry, rz;
private volatile int buttons;
+ /** Battery charge 0..100, or -1 while unknown (wired models never report it). */
+ private volatile int batteryPercent = -1;
+ private volatile boolean charging;
/** Set when a read error (typically unplug) killed the reader. */
private volatile boolean broken;
- private SpaceNavigatorHid(final File deviceNode) {
+ private SpaceNavigatorHid(final File deviceNode, final Model model) {
this.deviceNode = deviceNode;
+ this.model = model;
}
/**
- * Finds and opens the first SpaceNavigator on the system, or null
- * when none is plugged in. Throws when the device is present but
+ * Finds and opens the first supported SpaceMouse on the system, or
+ * null when none is connected. Throws when a device is present but
* cannot be opened (permissions — see
* /etc/udev/rules.d/99-spacenavigator.rules).
*/
try {
final String content = new String(
java.nio.file.Files.readAllBytes(uevent.toPath()));
- if (!content.contains(HID_ID))
+ final Model model = Model.match(content);
+ if (model == null)
continue;
final File node = new File("/dev", entry.getName());
- final SpaceNavigatorHid hid = new SpaceNavigatorHid(node);
+ final SpaceNavigatorHid hid =
+ new SpaceNavigatorHid(node, model);
hid.openNode();
return hid;
} catch (final Exception e) {
- throw new RuntimeException("SpaceNavigator found at "
+ throw new RuntimeException("SpaceMouse found at "
+ entry.getName() + " but cannot be opened: "
+ e.getMessage());
}
broken = true; // unplugged
return;
}
- if (n < 7)
- continue;
- final int reportId = readBuffer.getByte(0) & 0xFF;
- if (reportId == 1) {
+ parseReport(n);
+ }
+ }
+
+ private void parseReport(final int n) {
+ final int reportId = readBuffer.getByte(0) & 0xFF;
+ if (model == Model.SPACEMOUSE_WIRELESS_BT) {
+ // One 13-byte report carries all six axes; the button
+ // report is shorter than 7 bytes, so length checks are
+ // per-report here (a shared n < 7 guard would drop it).
+ if (reportId == 1 && n >= 13) {
tx = readBuffer.getShort(1);
ty = readBuffer.getShort(3);
tz = readBuffer.getShort(5);
- } else if (reportId == 2) {
- rx = readBuffer.getShort(1);
- ry = readBuffer.getShort(3);
- rz = readBuffer.getShort(5);
- } else if (reportId == 3) {
+ rx = readBuffer.getShort(7);
+ ry = readBuffer.getShort(9);
+ rz = readBuffer.getShort(11);
+ } else if (reportId == 3 && n >= 2) {
buttons = readBuffer.getByte(1) & 0xFF;
+ } else if (reportId == 0x17 && n >= 3) {
+ batteryPercent = readBuffer.getByte(1) & 0xFF;
+ charging = (readBuffer.getByte(2) & 1) != 0;
}
+ return;
+ }
+ // SpaceNavigator USB: 7-byte reports, translation and rotation
+ // delivered separately.
+ if (n < 7)
+ return;
+ if (reportId == 1) {
+ tx = readBuffer.getShort(1);
+ ty = readBuffer.getShort(3);
+ tz = readBuffer.getShort(5);
+ } else if (reportId == 2) {
+ rx = readBuffer.getShort(1);
+ ry = readBuffer.getShort(3);
+ rz = readBuffer.getShort(5);
+ } else if (reportId == 3) {
+ buttons = readBuffer.getByte(1) & 0xFF;
}
}
return running && !broken;
}
+ /** Which supported device this instance talks to. */
+ public Model getModel() {
+ return model;
+ }
+
public int getTx() { return tx; }
public int getTy() { return ty; }
public int getTz() { return tz; }
public int getRz() { return rz; }
public int getButtons() { return buttons; }
+ /**
+ * Battery charge 0..100 as last reported by a wireless device,
+ * or -1 when the device has not reported it (wired models never do).
+ */
+ public int getBatteryPercent() { return batteryPercent; }
+
+ /** True when a wireless device last reported itself charging. */
+ public boolean isCharging() { return charging; }
+
public void stop() {
running = false;
// Join first: the poll loop notices the flag within 250 ms. Closing
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.function.Supplier;
/**
- * Progressive CPU global illumination, running on dedicated low-priority
- * threads (never on the render ForkJoinPool).
+ * CPU global illumination baked in strict bounce generations, running on
+ * dedicated low-priority threads (never on the render ForkJoinPool).
*
- * <p><b>Phased scheduling (default, {@code -De3d.gi.phases=true}):</b>
- * the scene is lit in three strict global phases, each ordered by polygon
- * distance from the camera, near first:</p>
+ * <p><b>Generations:</b> the scene is lit generation by generation, each
+ * walking all snapshot entries near-to-far from the camera, with a
+ * global barrier between generations:</p>
* <ol>
- * <li><b>A — centroid direct:</b> each polygon gets one direct-light
- * evaluation at its centroid (shadow rays to all lights). The
- * result is stamped onto the whole polygon at once — the world
- * lights up polygon by polygon as a visible near-to-far wave
- * instead of staying at the uniform medium start until a full
- * per-texel sweep completes (which does not scale to huge
- * scenes).</li>
- * <li><b>B — centroid multi-bounce:</b> bounce rays from centroids,
- * still one sample point per polygon, feeding the indirect field.
- * Cheap enough to converge for the whole scene before any texel
- * work starts.</li>
- * <li><b>C — per-texel refinement:</b> lightmapped triangles are
- * refined near-to-far; a triangle entering this phase has its
- * texels seeded from its converged centroid values, so detail
- * glides in without a second dark age. A lightmap graduates once
- * every texel has been re-sampled at least once — so no
- * centroid-seeded shadow value survives — and its on-screen
- * estimate has stopped moving; ray sampling then stops and a
- * freeze tail keeps recompositing the now-fixed target until the
- * on-screen estimate has fully glided in — without the tail,
- * graduation would freeze a few light units of residual
- * per-triangle stamp bias into the texture forever, visible as
- * seams between adjacent triangles. When every lightmap has
- * display-frozen, the workers idle.</li>
+ * <li><b>Generation 1 — direct:</b> every lightmap texel casts a
+ * shadow ray toward every light; the outcome is cached in the
+ * texel's visibility bytes. Plain polygons and mesh-block
+ * triangles get one centroid evaluation instead (their final
+ * resolution). Direct light is always derived from the cached
+ * visibility, never stored separately.</li>
+ * <li><b>Generation k >= 2 — one more bounce:</b> every texel casts
+ * {@code e3d.gi.samples} cosine-weighted bounce rays and averages
+ * them (a plain running mean — within a generation the sampled
+ * field is frozen by the barrier, so the samples are independent
+ * and no EMA is needed). Generation 2 gathers the hit point's
+ * direct light; generation k >= 3 gathers the hit surface's
+ * generation-(k-1) delta only, so each generation adds exactly one
+ * more light bounce to the solution.</li>
* </ol>
*
- * <p>Phases B and C run on a bounded active window
- * ({@code -De3d.gi.activeWindow}, default 256 entries) so near geometry
- * converges before CPU is spent on far geometry. When the camera moves
- * more than {@code -De3d.gi.resortDistance} (default 25 world units)
- * the not-yet-activated work is re-sorted by distance to the new
- * position. Scene or light changes rebuild the snapshot and restart
- * from phase A. Set {@code -De3d.gi.phases=false} for the legacy flat
- * round-robin scheduler.</p>
+ * <p><b>Termination:</b> the bake stops when a generation's mean delta
+ * falls below {@code e3d.gi.deltaThreshold} (later generations only
+ * shrink — the 1/pi diffuse gain keeps the loop gain below one) or when
+ * {@code e3d.gi.generations} generations have run (default 6 = direct +
+ * up to 5 bounce generations). The uncaptured series tail is the price
+ * of the hard stop; raise the knob for bright interiors.</p>
+ *
+ * <p><b>Storage lifetime:</b> per-texel arrays (indirect total, two
+ * delta buffers, visibility) are allocated lazily when the bake reaches
+ * a lightmap and <b>freed for the whole scene when the bake
+ * completes</b> — a finished lightmap keeps only its final composite
+ * texture. Plain polygons keep their handful of per-polygon floats
+ * (render threads read them every frame through {@link GiLightProvider}).
+ * A scene or light change rebuilds the snapshot and restarts the bake
+ * from generation 1; the old textures stay on screen until the new
+ * direct pass lands.</p>
*
* <p><b>Two sampling resolutions:</b></p>
* <ul>
* <li><b>Lightmapped triangles</b> ({@link LightmappedShape}, e.g. the
* wrapped polygons of a lightmapping-enabled
* {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}):
- * per-texel sampling in phase C. Shadows and gradients live INSIDE
- * the polygon surface; the painted texture is the premultiplied
+ * per-texel sampling. Shadows and gradients live INSIDE the
+ * polygon surface; the painted texture is the premultiplied
* composite (baseColor x ambient+direct+indirect), regenerated on
- * GI threads and swapped in double-buffered — painters never see a
- * half-updated texture.</li>
- * <li><b>Plain solid polygons</b>: per-polygon sampling (complete after
- * phase B); the result feeds the flat-shading path through
- * {@link GiLightProvider} (shadow tests + indirect add). Polygons
- * stay single-colored.</li>
+ * GI threads every 500 ms during the bake and swapped in
+ * double-buffered — painters never see a half-updated texture.</li>
+ * <li><b>Plain solid polygons</b>: per-polygon sampling; the result
+ * feeds the flat-shading path through {@link GiLightProvider}
+ * (shadow tests + indirect add). Polygons stay single-colored.</li>
* <li><b>Mesh blocks</b> ({@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TriangleMeshBlock},
* lean flat-array game geometry): per-triangle sampling like plain
- * polygons, complete after phase B. Instead of the GiLightProvider
- * path (which needs per-triangle objects), light totals are written
- * straight into the block's {@code giLight} array; the paint path
- * multiplies texels by them. Blocks never enter phase C (no
- * lightmaps).</li>
+ * polygons. Light totals (direct + indirect) are written straight
+ * into the block's {@code giLight} array at each generation
+ * barrier; the paint path multiplies texels by them.</li>
* </ul>
*
- * <p><b>Estimator:</b> each bounce sample casts one cosine-weighted
- * hemisphere ray from the surface. At the hit it evaluates direct light
- * with cached shadow tests (next-event estimation) plus the hit
- * surface's current indirect estimate, so bounce light propagates deeper
- * over sweeps without an explicit recursion limit. In phase C two nested
- * exponential moving averages shape what the user sees: the inner
- * per-sample EMA smooths Monte Carlo noise in the indirect term, and the
- * outer per-composite-update EMA wraps the complete sum
- * ambient+direct+indirect with a fixed alpha, so refinement glides —
- * no black-to-lit flash or shadow pop after the deliberate phase-A
- * stamp. When converged, workers idle at a low cadence instead of
- * burning CPU.</p>
- *
* <p><b>Render-side cost:</b> zero ray casting. Lightmapped triangles
* paint from their current composite texture; the flat-shading path only
* reads cached per-polygon values. Frame rate is unaffected by GI
/** Shadow ray segments end this far before the light to avoid grazing hits. */
private static final double LIGHT_EPSILON = 1.0;
- /** Minimum sleep between sweeps once the solution has converged. */
+ /** Sleep between scene-change polls once the bake has completed. */
private static final long IDLE_SLEEP_MS = 250;
- /** Maximum converged-state sleep: edits restart tracing at this latency. */
- private static final long IDLE_SLEEP_MAX_MS = 2000;
-
/** Minimum interval between composite texture updates. */
private static final long COMPOSITE_INTERVAL_MS = 500;
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}.
+ * Total generations to trace: generation 1 = direct light, each
+ * further generation adds one more bounce. The bake may stop earlier
+ * on the delta threshold. {@code -De3d.gi.generations}.
*/
- private static final int ACTIVE_WINDOW =
- Integer.parseInt(System.getProperty("e3d.gi.activeWindow", "256"));
+ private static final int MAX_GENERATIONS =
+ Integer.parseInt(System.getProperty("e3d.gi.generations", "6"));
/**
- * Camera translation (world units) that triggers a re-sort of the
- * not-yet-activated work queue. {@code -De3d.gi.resortDistance}.
+ * Bounce rays per texel (or polygon) per generation, averaged as a
+ * plain mean. Total rays per texel over a full bake is roughly this
+ * times the generation count. {@code -De3d.gi.samples}.
*/
- 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;
+ private static final int SAMPLES_PER_GENERATION =
+ Integer.parseInt(System.getProperty("e3d.gi.samples", "8"));
/**
- * 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}.
+ * Termination: stop the bake when a generation's mean absolute
+ * per-texel delta falls below this many light units. Later
+ * generations only shrink (the diffuse loop gain is below one), so a
+ * small delta means the remaining bounces cannot move the picture.
+ * {@code -De3d.gi.deltaThreshold}.
*/
- private static final double FREEZE_THRESHOLD =
- Double.parseDouble(System.getProperty("e3d.gi.freezeThreshold", "0.1"));
+ private static final double DELTA_THRESHOLD =
+ Double.parseDouble(System.getProperty("e3d.gi.deltaThreshold", "0.5"));
- /**
- * EMA policy for the inner per-sample indirect blend: "fixed" (default,
- * 0.15) keeps every ray hit equally intensive forever — fading toward
- * darkness stays as alive as brightening. "adaptive" decays alpha with
- * sample count (lower final noise, but late-time adaptation nearly
- * stops). {@code -De3d.gi.alphaMode}.
- */
- private static final String ALPHA_MODE = System.getProperty("e3d.gi.alphaMode", "fixed");
- /** Adaptive alpha floor: keeps post-change fade alive. */
- private static final double ALPHA_FLOOR = Double.parseDouble(System.getProperty("e3d.gi.alphaFloor", "0.08"));
/** Spatial despeckle of indirect at composite time. */
private static final boolean DESPECKLE = Boolean.parseBoolean(System.getProperty("e3d.gi.despeckle", "true"));
- /**
- * Outer EMA: fraction of the freshly computed total irradiance blended
- * into the on-screen composite estimate per update. Lower = slower,
- * calmer fade (and less visible Monte Carlo noise); higher = faster
- * reaction. At the default 0.2 and 500 ms update cadence the scene
- * reaches its true lighting in roughly 5 seconds.
- * {@code -De3d.gi.compositeAlpha}.
- */
- private static final double COMPOSITE_ALPHA =
- Double.parseDouble(System.getProperty("e3d.gi.compositeAlpha", "0.2"));
-
- /**
- * 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"));
-
/**
* Minimum milliseconds between scene snapshot rebuilds: streaming
* worlds bump the render-list version per loaded cell, and rebuilding
private static final long MIN_REBUILD_MS =
Long.parseLong(System.getProperty("e3d.gi.minRebuildMs", "1000"));
- /** 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. */
+ /** Camera position source for the near-to-far bake order; null disables sorting. */
private final Supplier<Point3D> cameraPosition;
private final List<Thread> threads = new ArrayList<>();
private volatile Snapshot snapshot;
- /** Progressive per-polygon GI state, keyed by polygon or by entry (mesh blocks). */
+ /** Per-polygon GI state, keyed by polygon or by entry (mesh blocks). */
private final ConcurrentHashMap<Object, GiState> states = new ConcurrentHashMap<>();
private int lastSeenRenderListVersion = -1;
/** Single-flight guard for snapshot rebuilds (one worker builds at a time). */
private final java.util.concurrent.atomic.AtomicBoolean rebuildInFlight =
new java.util.concurrent.atomic.AtomicBoolean();
- private final AtomicInteger workIndex = new AtomicInteger();
- private volatile int calmSweeps;
private volatile long lastCompositeUpdate;
-
- /** Last DEBUG heartbeat timestamp (any worker). */
- private volatile long lastHeartbeat;
private final java.util.concurrent.atomic.AtomicBoolean compositeUpdateInFlight =
new java.util.concurrent.atomic.AtomicBoolean();
-
- // --- Phased scheduler state (guarded by queueLock except where noted) ---
-
- /** Current phase; volatile, workers read it every iteration. */
- private volatile Phase phase = Phase.CENTROID_DIRECT;
- private final Object queueLock = new Object();
- /** Work queue of the current phase, sorted near-to-far. */
- private TriangleBvh.Entry[] phaseQueue = new TriangleBvh.Entry[0];
- /** Phase A: next entry to compute. Phases B/C: next entry to activate. */
- private int queueCursor;
- /** Phase A visits currently being processed by a worker. */
- private final AtomicInteger inFlight = new AtomicInteger();
- /** Active window entries (phases B/C). */
- private final List<TriangleBvh.Entry> activeList = new ArrayList<>();
- /** Published snapshot of the active window for lock-free round-robin. */
- private volatile TriangleBvh.Entry[] active = new TriangleBvh.Entry[0];
- /** Round-robin cursor over {@link #active}. */
- private final AtomicInteger activeCursor = new AtomicInteger();
- /** Camera position at snapshot build / last re-sort. */
- private double lastCameraX = Double.NaN, lastCameraY, lastCameraZ;
+ /** Serializes generation barriers. */
+ private final Object genLock = new Object();
private static class Snapshot {
List<TriangleBvh.Entry> entries;
List<LightSource> lights;
IdentityHashMap<LightSource, Integer> lightIndex;
double ambientR, ambientG, ambientB;
- /** Flattened work list (legacy mode): one item per lightmap texel / plain polygon. */
- WorkItem[] workItems;
/** All lightmaps in the snapshot (for composite updates). */
List<Lightmap> lightmaps;
- /** Phased mode: all entries sorted near-to-far from the camera. */
- TriangleBvh.Entry[] sortedByDistance;
- /** Phased mode: lightmapped entries only, sorted near-to-far. */
- TriangleBvh.Entry[] lightmappedSorted;
- /** Phased mode: lightmap back to its snapshot entry (phase C graduation). */
- IdentityHashMap<Lightmap, TriangleBvh.Entry> lightmapEntry;
- /** Phase C lightmaps that graduated sampling but have not display-frozen yet. */
- int tailCount;
- }
-
- /** One unit of legacy GI work: a lightmap texel, or a whole plain polygon. */
- private static class WorkItem {
- TriangleBvh.Entry entry;
- int texel; // -1 = plain polygon
+ /** Work queue of the active generation, sorted near-to-far. */
+ TriangleBvh.Entry[] queue;
+ /** Next queue index to sample (workers overshoot into nulls once drained). */
+ final AtomicInteger cursor = new AtomicInteger();
+ /** Entries currently being sampled (the barrier waits for zero). */
+ final AtomicInteger inFlight = new AtomicInteger();
+ /** The generation currently being sampled: 1 = direct, k = k-th bounce. */
+ volatile int generation = 1;
+ /** True once the bake has completed; workers idle until the next rebuild. */
+ volatile boolean baked;
}
- /** Progressive per-polygon GI state for plain solid polygons. */
+ /** Per-polygon GI state for plain solid polygons and mesh-block triangles. */
private static class GiState {
- volatile float indirectR, indirectG, indirectB; // irradiance, light units
- /** Phase-A direct stamp (ambient + direct), the block write-back base. */
+ /** Accumulated indirect total (light units); render threads read this. */
+ volatile float indirectR, indirectG, indirectB;
+ /** Generation-1 direct stamp (ambient + direct), the block write-back base. */
volatile float directR, directG, directB;
+ /** Completed generation's delta: the gather source of the next generation. */
+ float deltaPrevR, deltaPrevG, deltaPrevB;
+ /** In-progress generation accumulator (owning worker only). */
+ float deltaCurR, deltaCurG, deltaCurB;
volatile long visibleBits; // per-light: shadow ray says visible
volatile long knownBits; // per-light: visibility computed at least once
- int nextLight; // round-robin cursor (GI threads only)
- int samples; // adaptive EMA counter (GI threads only)
}
/**
/**
* Creates the GI system. Call {@link #start()} to begin tracing.
- * Distance ordering is disabled (uniform order, no camera re-sort).
+ * Distance ordering is disabled (uniform order).
*
* @param shapes the scene to trace
* @param lightingManager the lights to sample
* @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
+ * @param cameraPosition supplies the camera position for the
+ * near-to-far bake order; null disables
* distance ordering (uniform order)
*/
public GlobalIllumination(final ShapeCollection shapes,
}
/**
- * Returns whether the solution has converged (workers idling at a low
- * 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).
+ * Returns whether the bake has completed: all generations traced (or
+ * the delta threshold reached) and the per-texel arrays freed. The
+ * workers then idle at a low cadence, watching for scene changes.
*
- * @return {@code true} when converged
+ * @return {@code true} when baked
*/
public boolean isConverged() {
- return PHASES ? phase == Phase.DONE : calmSweeps >= 5;
+ final Snapshot snap = snapshot;
+ return snap != null && snap.baked;
}
/**
- * 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.
+ * Returns the number of traceable entries (triangles) in the current
+ * scene snapshot. 0 when no snapshot has been built yet.
*
- * @return the work unit count
+ * @return the entry count
*/
public int getWorkItemCount() {
final Snapshot snap = snapshot;
- if (snap == null)
- return 0;
- if (PHASES)
- return snap.sortedByDistance == null ? 0 : snap.sortedByDistance.length;
- return snap.workItems == null ? 0 : snap.workItems.length;
+ return snap == null ? 0 : snap.entries.size();
}
// ------------------------------------------------------------------
private void workLoop() {
final TriangleBvh.Hit hit = new TriangleBvh.Hit();
final double[] pos = new double[3];
+ final double[] target = new double[3];
while (running) {
try {
maybeRebuildSnapshot();
continue;
}
- final long sweepStart = System.currentTimeMillis();
- if (PHASES)
- phasedStep(snap, hit, pos);
- else
- legacySweep(snap, hit, pos);
+ if (snap.baked) {
+ // Bake complete: idle until a scene/light change
+ // rebuilds the snapshot and restarts from generation 1.
+ Thread.sleep(IDLE_SLEEP_MS);
+ continue;
+ }
+
+ final TriangleBvh.Entry entry = grabEntry(snap);
+ if (entry != null) {
+ try {
+ processEntry(snap, entry, hit, pos, target);
+ } finally {
+ snap.inFlight.decrementAndGet();
+ }
+ } else {
+ advanceGeneration(snap);
+ Thread.sleep(5);
+ }
// Regenerate composite textures at most every
// COMPOSITE_INTERVAL_MS; a paint pass is one texture swap
updateComposites(snap);
lastCompositeUpdate = now;
}
-
- // 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. 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;
} catch (final Exception e) {
}
}
- /** 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)
+ /** Takes the next unsampled entry of the active generation, or null once drained. */
+ private TriangleBvh.Entry grabEntry(final Snapshot snap) {
+ final int index = snap.cursor.getAndIncrement();
+ if (index >= snap.queue.length)
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]);
+ snap.inFlight.incrementAndGet();
+ return snap.queue[index];
}
- /**
- * 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);
+ /** Dispatches one entry to the pass of the active generation. */
+ private void processEntry(final Snapshot snap, final TriangleBvh.Entry entry,
+ final TriangleBvh.Hit hit, final double[] pos,
+ final double[] target) {
+ // The generation cannot advance while this entry is in flight
+ // (the barrier waits for zero in-flight entries), so the read is
+ // stable for the whole pass.
+ if (snap.generation == 1)
+ directPass(snap, entry, pos);
+ else
+ bouncePass(snap, entry, snap.generation, hit, pos, target);
}
// ------------------------------------------------------------------
- // Sampling
+ // Generation 1: direct light. Lightmaps trace every texel against
+ // every light and cache the visibility bytes; plain polygons and
+ // mesh blocks get one centroid evaluation (their final resolution).
// ------------------------------------------------------------------
- /** 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) {
- if (item.entry.lightmap != null)
- return sampleTexel(snap, item.entry, item.texel, hit, pos);
- return samplePlain(snap, item.entry, true, hit);
- }
-
- /**
- * 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.
+ private void directPass(final Snapshot snap, final TriangleBvh.Entry entry,
+ final double[] pos) {
final int lightCount = snap.lights.size();
- final boolean firstVisit = lightmap.sampleCounts[texel] == 0;
- if (firstVisit)
- lightmap.texelsSampled++; // graduation requires full coverage
- if (lightCount > 0) {
- lightmap.ensureLightCapacity(lightCount);
- if (firstVisit) {
- for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++)
+ final int tracked = Math.min(lightCount, MAX_TRACKED_LIGHTS);
+ final Lightmap lightmap = entry.lightmap;
+
+ if (lightmap != null) {
+ lightmap.ensureArrays(lightCount);
+ for (final int texel : lightmap.validTexels) {
+ lightmap.texelWorldPosition(texel, pos);
+ final double ox = pos[0] + lightmap.normalX * ORIGIN_EPSILON;
+ final double oy = pos[1] + lightmap.normalY * ORIGIN_EPSILON;
+ final double oz = pos[2] + lightmap.normalZ * ORIGIN_EPSILON;
+ for (int i = 0; i < tracked; i++)
lightmap.lightVisibility[texel * lightCount + i] =
- shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(i))
+ shadowTest(snap, ox, oy, oz,
+ lightmap.normalX, lightmap.normalY, lightmap.normalZ,
+ snap.lights.get(i))
? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
- } else {
- 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 + 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;
- }
-
- /**
- * 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(stateKey(entry), 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;
}
+ lightmap.generation = 1;
+ return;
}
- 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) (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) {
+ // Plain polygon / mesh-block triangle: one centroid evaluation.
final double nx = entry.normal[0];
final double ny = entry.normal[1];
final double nz = entry.normal[2];
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;
+ final boolean[] visible = new boolean[tracked];
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);
b += lightColor.b * intensity;
}
+ final GiState state = states.computeIfAbsent(stateKey(entry), p -> new GiState());
+ synchronized (state) {
+ 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;
+ }
+ state.directR = (float) Math.min(255, r);
+ state.directG = (float) Math.min(255, g);
+ state.directB = (float) Math.min(255, b);
+ // A bake restart over previously used state: the old
+ // accumulated indirect must not leak into the new solution.
+ state.indirectR = 0;
+ state.indirectG = 0;
+ state.indirectB = 0;
+ state.deltaPrevR = 0;
+ state.deltaPrevG = 0;
+ state.deltaPrevB = 0;
+ }
+ if (entry.block != null)
+ entry.block.setGiLight(entry.blockTri,
+ state.directR, state.directG, state.directB);
+ }
+
+ // ------------------------------------------------------------------
+ // Generation k >= 2: one more bounce. Every sample point casts
+ // SAMPLES_PER_GENERATION cosine-weighted rays and averages them as a
+ // plain running mean — the generation barrier freezes the sampled
+ // field, so the samples are independent and no EMA is needed.
+ // ------------------------------------------------------------------
+
+ private void bouncePass(final Snapshot snap, final TriangleBvh.Entry entry,
+ final int generation, final TriangleBvh.Hit hit,
+ final double[] pos, final double[] target) {
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(stateKey(entry), p -> new GiState());
- synchronized (state) {
- 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;
+ final double nx = lightmap.normalX;
+ final double ny = lightmap.normalY;
+ final double nz = lightmap.normalZ;
+ for (final int texel : lightmap.validTexels) {
+ lightmap.texelWorldPosition(texel, pos);
+ final double ox = pos[0] + nx * ORIGIN_EPSILON;
+ final double oy = pos[1] + ny * ORIGIN_EPSILON;
+ final double oz = pos[2] + nz * ORIGIN_EPSILON;
+ float meanR = 0, meanG = 0, meanB = 0;
+ for (int s = 1; s <= SAMPLES_PER_GENERATION; s++) {
+ gatherTarget(snap, generation, nx, ny, nz, ox, oy, oz, hit, target);
+ meanR += (target[0] - meanR) / s;
+ meanG += (target[1] - meanG) / s;
+ meanB += (target[2] - meanB) / s;
}
- state.directR = (float) Math.min(255, r);
- state.directG = (float) Math.min(255, g);
- state.directB = (float) Math.min(255, b);
+ lightmap.deltaCurR[texel] = meanR;
+ lightmap.deltaCurG[texel] = meanG;
+ lightmap.deltaCurB[texel] = meanB;
}
- if (entry.block != null)
- // The visible wave lands on the block immediately: the
- // triangle flips from the full-texture start to its true
- // flat lighting (bounce adds on top in phase B).
- entry.block.setGiLight(entry.blockTri,
- (float) Math.min(255, r),
- (float) Math.min(255, g),
- (float) Math.min(255, b));
+ return;
}
- }
- /**
- * 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 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);
+ float meanR = 0, meanG = 0, meanB = 0;
+ for (int s = 1; s <= SAMPLES_PER_GENERATION; s++) {
+ gatherTarget(snap, generation, nx, ny, nz, ox, oy, oz, hit, target);
+ meanR += (target[0] - meanR) / s;
+ meanG += (target[1] - meanG) / s;
+ meanB += (target[2] - meanB) / s;
}
final GiState state = states.computeIfAbsent(stateKey(entry), 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;
- if (entry.block != null)
- // Block paint reads the TOTAL light: phase-A direct stamp
- // plus the converging bounce estimate.
- entry.block.setGiLight(entry.blockTri,
- state.directR + state.indirectR,
- state.directG + state.indirectG,
- state.directB + state.indirectB);
- return graduationSignal(
- Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha,
- (state.indirectR + state.indirectG + state.indirectB) / 3.0);
+ state.deltaCurR = meanR;
+ state.deltaCurG = meanG;
+ state.deltaCurB = meanB;
}
}
- /**
- * 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.
+ * Generation 2 gathers the hit point's direct light (from cached
+ * visibility, no new shadow rays); later generations gather only the
+ * hit surface's previous-generation delta, so each generation adds
+ * exactly one more light bounce.
*
- * @return bounce target radiance contribution {r, g, b}, zeros on a miss
+ * @param out receives the 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) {
+ private void gatherTarget(final Snapshot snap, final int generation,
+ final double nx, final double ny, final double nz,
+ final double ox, final double oy, final double oz,
+ final TriangleBvh.Hit hit, final double[] out) {
+ out[0] = 0;
+ out[1] = 0;
+ out[2] = 0;
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];
+ return;
- // 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];
+ final double eR, eG, eB;
+ if (generation == 2) {
+ // Direct irradiance at the hit point (clamped to display
+ // range): light near a lamp can sum far beyond 255, and
+ // feeding unbounded energy into the bounce loop saturates
+ // the scene.
+ final double[] irr = directIrradiance(snap, hit);
+ eR = irr[0];
+ eG = irr[1];
+ eB = irr[2];
} else {
- final GiState hitState = states.get(stateKey(hit.entry));
- hiR = hitState == null ? 0 : hitState.indirectR;
- hiG = hitState == null ? 0 : hitState.indirectG;
- hiB = hitState == null ? 0 : hitState.indirectB;
+ final TriangleBvh.Entry hitEntry = hit.entry;
+ if (hitEntry.lightmap != null) {
+ final Lightmap lm = hitEntry.lightmap;
+ final float[] prevR = lm.deltaPrevR;
+ if (prevR == null)
+ return; // raced with a bake restart: no gather source yet
+ final int hitTexel = lm.texelAt(hit.pointX, hit.pointY, hit.pointZ);
+ eR = prevR[hitTexel];
+ eG = lm.deltaPrevG[hitTexel];
+ eB = lm.deltaPrevB[hitTexel];
+ } else {
+ final GiState hitState = states.get(stateKey(hitEntry));
+ if (hitState == null)
+ return;
+ eR = hitState.deltaPrevR;
+ eG = hitState.deltaPrevG;
+ eB = hitState.deltaPrevB;
+ }
+ }
+
+ final Color hitColor = colorOf(hit.entry);
+ out[0] = BOUNCE_GAIN * hitColor.r * eR / 255.0;
+ out[1] = BOUNCE_GAIN * hitColor.g * eG / 255.0;
+ out[2] = BOUNCE_GAIN * hitColor.b * eB / 255.0;
+ }
+
+ // ------------------------------------------------------------------
+ // Generation barrier
+ // ------------------------------------------------------------------
+
+ /**
+ * Rolls the scene over to the next generation once the queue is
+ * drained and no sampling is in flight: folds every entry's finished
+ * delta into its accumulated total, publishes the delta as the next
+ * generation's gather source, then either advances the generation
+ * counter or — on the generation cap / a calm delta — completes the
+ * bake with a final composite pass and frees all per-texel arrays.
+ */
+ private void advanceGeneration(final Snapshot snap) {
+ synchronized (genLock) {
+ if (snap.baked)
+ return; // teardown already done by another worker
+ if (snap.cursor.get() < snap.queue.length || snap.inFlight.get() > 0)
+ return;
+ if (snap != snapshot)
+ return; // a rebuild replaced the snapshot while we waited
+
+ final int finished = snap.generation;
+ double energy = 0;
+ long units = 0;
+ if (finished >= 2) {
+ for (final Lightmap lightmap : snap.lightmaps) {
+ energy += lightmap.rollover();
+ units += lightmap.validTexels.length;
+ }
+ for (final GiState state : states.values()) {
+ synchronized (state) {
+ energy += Math.max(Math.abs(state.deltaCurR),
+ Math.max(Math.abs(state.deltaCurG), Math.abs(state.deltaCurB)));
+ units++;
+ state.indirectR += state.deltaCurR;
+ state.indirectG += state.deltaCurG;
+ state.indirectB += state.deltaCurB;
+ state.deltaPrevR = state.deltaCurR;
+ state.deltaPrevG = state.deltaCurG;
+ state.deltaPrevB = state.deltaCurB;
+ state.deltaCurR = 0;
+ state.deltaCurG = 0;
+ state.deltaCurB = 0;
+ }
+ }
+ // Mesh blocks paint from their giLight array: publish
+ // direct + accumulated indirect at the barrier.
+ for (final TriangleBvh.Entry entry : snap.entries) {
+ if (entry.block == null)
+ continue;
+ final GiState state = states.get(entry);
+ if (state != null)
+ entry.block.setGiLight(entry.blockTri,
+ state.directR + state.indirectR,
+ state.directG + state.indirectG,
+ state.directB + state.indirectB);
+ }
+ }
+ final double meanDelta = units == 0 ? 0 : energy / units;
+
+ if (finished >= MAX_GENERATIONS || (finished >= 2 && meanDelta < DELTA_THRESHOLD)) {
+ // Final composite with no sampling in flight, then free
+ // all per-texel state. Holding the composite single-flight
+ // keeps a concurrent timed updateComposites from racing
+ // the last writes; baked=true makes later attempts skip.
+ while (!compositeUpdateInFlight.compareAndSet(false, true))
+ Thread.yield();
+ try {
+ for (final Lightmap lightmap : snap.lightmaps) {
+ compositeLightmap(snap, lightmap);
+ lightmap.freeArrays();
+ }
+ snap.baked = true;
+ } finally {
+ compositeUpdateInFlight.set(false);
+ }
+ if (DEBUG)
+ System.out.println("[GI] bake done after generation " + finished
+ + ", mean delta " + String.format("%.3f", meanDelta)
+ + " — texel arrays freed");
+ } else {
+ snap.generation = finished + 1;
+ snap.cursor.set(0);
+ if (DEBUG)
+ System.out.println("[GI] generation " + finished + " done, mean delta "
+ + String.format("%.3f", meanDelta)
+ + " -> generation " + (finished + 1));
+ }
}
- 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};
}
+ // ------------------------------------------------------------------
+ // Ray primitives
+ // ------------------------------------------------------------------
+
/** Shadow ray from a surface point toward a light. */
private boolean shadowTest(final Snapshot snap,
final double ox, final double oy, final double oz,
/**
* Direct irradiance at a ray hit point, same scale as LightingManager,
- * clamped to display range: light units near a lamp can sum far beyond
- * 255 and feeding unbounded energy into the bounce loop saturates the
- * scene. Uses the hit surface's CACHED visibility (no new shadow rays).
+ * clamped to display range. Uses the hit surface's CACHED visibility
+ * (no new shadow rays).
*/
private double[] directIrradiance(final Snapshot snap, final TriangleBvh.Hit hit) {
final TriangleBvh.Entry entry = hit.entry;
// ------------------------------------------------------------------
private void updateComposites(final Snapshot snap) {
- // Single flight: both workers finish sweeps concurrently and must
- // not write the same back buffers simultaneously.
+ // Single flight: several workers must not write the same back
+ // buffers simultaneously.
if (!compositeUpdateInFlight.compareAndSet(false, true))
return;
try {
- if (PHASES)
- updateCompositesPhased(snap);
- else
- updateCompositesLegacy(snap);
+ if (snap.baked)
+ return;
+ for (final Lightmap lightmap : snap.lightmaps)
+ if (!lightmap.baked && lightmap.generation >= 1)
+ compositeLightmap(snap, lightmap);
} 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);
- }
- } else {
- lightmap.calmComposites = 0;
- }
- 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;
- }
- }
- }
- }
-
- /** 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)
+ * per-texel visibility and indirect state: direct light derived from
+ * the visibility bytes, plus the accumulated indirect total with the
+ * in-progress generation's mean as a live preview, despeckled. No
+ * display EMA — the texture shows the current bake state directly.
*/
- private double compositeTexels(final Snapshot snap, final Lightmap lightmap,
- final int lightCount, final double[] pos) {
- lightmap.ensureLightCapacity(lightCount);
+ private void compositeLightmap(final Snapshot snap, final Lightmap lightmap) {
+ final float[] indR = lightmap.indirectR;
+ final float[] indG = lightmap.indirectG;
+ final float[] indB = lightmap.indirectB;
+ final float[] curR = lightmap.deltaCurR;
+ final float[] curG = lightmap.deltaCurG;
+ final float[] curB = lightmap.deltaCurB;
+ if (indR == null || curR == null || lightmap.lightVisibility == null)
+ return; // raced with a bake restart/completion: nothing valid to show
+ final int lightCount = snap.lights.size();
+ if (lightmap.lightCount != lightCount)
+ return; // stale visibility from a previous snapshot (resize pending)
final int width = lightmap.width;
final int height = lightmap.height;
final int texelCount = width * height;
+ final double[] pos = new double[3];
- // 1. Total irradiance per valid texel (float, no clamping yet).
+ // 1. Indirect shown per texel: accumulated total + in-progress mean.
+ final float[] sumR = new float[texelCount];
+ final float[] sumG = new float[texelCount];
+ final float[] sumB = new float[texelCount];
+ for (final int texel : lightmap.validTexels) {
+ sumR[texel] = indR[texel] + curR[texel];
+ sumG[texel] = indG[texel] + curG[texel];
+ sumB[texel] = indB[texel] + curB[texel];
+ }
+
+ // 2. 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];
// 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];
+ final float smoothedR = DESPECKLE ? smoothedIndirect(sumR, lightmap, texel) : sumR[texel];
+ final float smoothedG = DESPECKLE ? smoothedIndirect(sumG, lightmap, texel) : sumG[texel];
+ final float smoothedB = DESPECKLE ? smoothedIndirect(sumB, lightmap, texel) : sumB[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;
}
- // 2. Fill the invalid half (u+v > 1) from nearest valid
+ // 3. 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)
}
}
- // 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;
+ // 4. Write the composite texture 1:1 from the irradiance.
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]);
+ pixels[t] = compositePixel(lightmap, irrR[t], irrG[t], irrB[t]);
}
back.resetResampledBitmapCache();
// 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");
if (DEBUG)
System.out.println("[GI] rebuild trigger: renderListVersion "
+ lastSeenRenderListVersion + " -> " + version
- + ", lightSignature " + lastLightSignature + " -> " + lightSignature
- + ", phase was " + phase);
+ + ", lightSignature " + lastLightSignature + " -> " + lightSignature);
final List<AbstractCoordinateShape> triangles = new ArrayList<>();
shapes.collectRenderTriangles(triangles);
// 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.
+ // rebuild from a consistent state instead of baking a partial
+ // scene and idling forever.
if (AbstractCompositeShape.getGlobalRenderListVersion() != version)
return;
snap.ambientG = ambient.g;
snap.ambientB = ambient.b;
- 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)
+ // Near-to-far bake order from the camera position at build time.
+ final Point3D cam = cameraPosition == null ? null : cameraPosition.get();
+ if (cam != null) {
+ for (final TriangleBvh.Entry entry : snap.entries) {
+ final double dx = entry.centroidX - cam.x;
+ final double dy = entry.centroidY - cam.y;
+ final double dz = entry.centroidZ - cam.z;
+ entry.distance = (float) (dx * dx + dy * dy + dz * dz);
+ }
+ snap.queue = snap.entries.stream()
.sorted(Comparator.comparingDouble(e -> e.distance))
.toArray(TriangleBvh.Entry[]::new);
- snap.lightmapEntry = new IdentityHashMap<>();
- for (final TriangleBvh.Entry entry : snap.entries)
- if (entry.lightmap != null) {
- entry.lightmap.resetPhasedState();
- snap.lightmapEntry.put(entry.lightmap, entry);
- }
- synchronized (queueLock) {
- phase = Phase.CENTROID_DIRECT;
- phaseQueue = snap.sortedByDistance;
- queueCursor = 0;
- // inFlight must NOT be zeroed here: a worker that grabbed
- // an entry just before the rebuild decrements it after,
- // so set(0) would drive the counter negative and the
- // phase-A drain condition (== 0) would never hold again.
- // The counter is conserved on its own: pre-rebuild
- // samples finish and decrement normally.
- activeList.clear();
- active = new TriangleBvh.Entry[0];
- activeCursor.set(0);
- }
- if (cam != null) {
- lastCameraX = cam.x;
- lastCameraY = cam.y;
- lastCameraZ = cam.z;
- } else {
- lastCameraX = Double.NaN;
- }
} else {
- // Flattened work list: one item per valid lightmap texel,
- // one per plain polygon.
- final List<WorkItem> workItems = new ArrayList<>();
- for (final TriangleBvh.Entry entry : snap.entries) {
- if (entry.lightmap != null) {
- for (final int texel : entry.lightmap.validTexels) {
- final WorkItem item = new WorkItem();
- item.entry = entry;
- item.texel = texel;
- workItems.add(item);
- }
- } else {
- final WorkItem item = new WorkItem();
- item.entry = entry;
- item.texel = -1;
- workItems.add(item);
- }
- }
- snap.workItems = workItems.toArray(new WorkItem[0]);
+ snap.queue = snap.entries.toArray(new TriangleBvh.Entry[0]);
}
snapshot = snap;
lastSeenRenderListVersion = version;
lastLightSignature = lightSignature;
lastRebuildTime = now;
- calmSweeps = 0; // scene changed: back to full-speed tracing
-
- 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");
- }
+
+ if (DEBUG)
+ System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, "
+ + snap.lightmaps.size() + " lightmaps, "
+ + snap.lights.size() + " lights, generation bake");
}
private double lightSignature() {
* smoothness comes from tracing at finer resolution (smaller
* unitsPerTexel), never from upsampling.</p>
*
- * <p>The GI system stores per-texel indirect irradiance and per-texel
- * per-light visibility here, and periodically regenerates the premultiplied
- * composite texture (baseColor x lighting) into the back buffer, then swaps
- * it onto the rendered triangle — painters never see a half-updated
- * texture.</p>
+ * <p><b>Bake state:</b> the GI system bakes lighting in strict global
+ * generations (1 = direct, k = k-th bounce generation). While baking,
+ * each lightmap carries three per-texel float field sets:</p>
+ * <ul>
+ * <li>{@link #indirectR}/{@link #indirectG}/{@link #indirectB} — the
+ * accumulated indirect total (sum of all completed generations),
+ * the value the composite texture displays.</li>
+ * <li>{@link #deltaPrevR}/{@link #deltaPrevG}/{@link #deltaPrevB} — the
+ * last completed generation's delta: the gather source for the
+ * generation currently being traced (strict ordering: generation
+ * k reads only generation k-1 output). Read-only between
+ * generation barriers.</li>
+ * <li>{@link #deltaCurR}/{@link #deltaCurG}/{@link #deltaCurB} — the
+ * in-progress generation's accumulator: a running mean over the
+ * samples cast so far this generation, composited as a live
+ * preview.</li>
+ * </ul>
*
- * <p><b>Gradual convergence:</b> what reaches the texture is never the raw
- * computed irradiance but a persistent per-texel exponential moving average
- * ({@link #estimateR}/{@link #estimateG}/{@link #estimateB}) over the
- * complete sum ambient+direct+indirect. The estimate starts at a uniform
- * medium value ({@link #INITIAL_IRRADIANCE}), so the world is visible from
- * frame one; lit areas then brighten and unlit areas sink to darkness
- * gradually — no black-to-lit flash is possible, since the texture can only
- * move a fixed alpha fraction per composite update.</p>
+ * <p>All texel arrays are allocated lazily when the bake reaches the
+ * lightmap (lightmapped scenes that never enable GI pay only the two
+ * composite textures) and are <b>freed when the global bake
+ * completes</b> — after that, the composite texture is final and the
+ * only remaining per-texel state. A scene or light change starts a new
+ * bake and re-allocates them.</p>
*
- * <p>Threading: texel state arrays are written by GI worker threads and read
- * by whoever holds the snapshot; element-wise racy access is benign for
- * progressive refinement. Texture buffer swaps are volatile/atomic via
- * {@link LightmappedTriangle#setTexture}.</p>
+ * <p>Threading: texel state arrays are written by the single GI worker
+ * that owns the entry and read by whoever holds the snapshot (other
+ * workers' bounce rays, the compositor); element-wise racy access is
+ * benign for progressive refinement. Texture buffer swaps are
+ * volatile/atomic via {@link LightmappedTriangle#setTexture}.</p>
*/
public class Lightmap {
private static final int MAX_SIZE = 128;
/**
- * Uniform irradiance the composite estimate starts at (light units,
- * display scale 0..255): the world begins medium-lit and visible, then
- * fades toward the traced solution. {@code -De3d.gi.initialIrradiance}.
+ * Uniform irradiance the composite texture starts at (light units,
+ * display scale 0..255): the world begins medium-lit and visible,
+ * then each lightmap flips to its traced direct light when the bake
+ * reaches it. {@code -De3d.gi.initialIrradiance}.
*/
public static final double INITIAL_IRRADIANCE =
Double.parseDouble(System.getProperty("e3d.gi.initialIrradiance", "128"));
/** Unit surface normal. */
public final double normalX, normalY, normalZ;
- /** Valid (u+v <= 1) texel indices, for round-robin sampling. */
+ /** Valid (u+v <= 1) texel indices, for generation sampling. */
public final int[] validTexels;
- /** Per-texel indirect irradiance (light units, pre-albedo). */
- public final float[] indirectR;
- public final float[] indirectG;
- public final float[] indirectB;
-
- /**
- * Per-texel EMA estimate of TOTAL irradiance (ambient + direct +
- * indirect), the only value ever written to the composite texture.
- * Initialized to {@link #INITIAL_IRRADIANCE} (uniform medium start);
- * each composite update blends the freshly computed irradiance in with
- * a fixed alpha, so both brightening and fading to darkness stay alive
- * forever and no single-frame jump can occur.
- */
- public final float[] estimateR;
- public final float[] estimateG;
- public final float[] estimateB;
-
/**
- * Per-texel sample counters. In "adaptive" alpha mode they drive the
- * decaying EMA weight; in the default "fixed" mode they only mark
- * first-visit texels (all-lights shadow test on the first sweep).
+ * Accumulated indirect irradiance total, pre-albedo light units: the
+ * sum of all completed bounce generations, displayed by the
+ * composite texture. Null before the bake reaches this lightmap and
+ * after the bake completes.
*/
- public final short[] sampleCounts;
-
- /** Per-texel per-light visibility: texelCount * lightCount bytes. */
- public byte[] lightVisibility;
- public int lightCount;
-
- // --- Phased progressive GI state (-De3d.gi.phases, default on) ---
+ public volatile float[] indirectR;
+ public volatile float[] indirectG;
+ public volatile float[] indirectB;
/**
- * 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.
+ * Last completed generation's indirect delta, pre-albedo light
+ * units: the gather source for the generation currently being
+ * traced. Null outside an active bake.
*/
- public volatile float centroidR, centroidG, centroidB;
+ public volatile float[] deltaPrevR;
+ public volatile float[] deltaPrevG;
+ public volatile float[] deltaPrevB;
/**
- * 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.
+ * In-progress generation accumulator: a running mean over the
+ * samples cast so far this generation. Written only by the worker
+ * that owns the entry; rolled into the total and swapped into
+ * {@link #deltaPrevR} at the generation barrier. Null outside an
+ * active bake.
*/
- public volatile float centroidIndirectR, centroidIndirectG, centroidIndirectB;
+ public volatile float[] deltaCurR;
+ public volatile float[] deltaCurG;
+ public volatile float[] deltaCurB;
/**
- * 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.
+ * Per-texel per-light visibility, written in generation 1:
+ * texelCount * lightCount bytes, indexed
+ * {@code texel * lightCount + lampIndex}. Direct light is always
+ * derived from these bytes (by the compositor and by generation-2
+ * bounce rays), never stored separately. Null outside an active
+ * bake.
*/
- 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;
+ public byte[] lightVisibility;
+ public int lightCount;
- /**
- * 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;
+ /** Completed generations: 0 = none, 1 = direct, k = k generations done. */
+ public volatile int generation;
- /** Last composited centroid totals; skips redundant uniform recomposites. */
- private float lastCompositeR = Float.NaN, lastCompositeG, lastCompositeB;
+ /** True once the bake has finished and the texel arrays are freed. */
+ public volatile boolean baked;
/** Double-buffered composite textures; the triangle shows one, GI fills the other. */
private final Texture[] buffers = new Texture[2];
/** The triangle currently displaying this lightmap (for texture swaps). */
public volatile LightmappedTriangle owner;
- /** Round-robin light cursor for shadow sampling (GI threads only). */
- public int nextLight;
-
/**
* Creates a lightmap for a triangle.
*
width = powerOfTwo(len1 / unitsPerTexel);
height = powerOfTwo(len2 / unitsPerTexel);
- indirectR = new float[width * height];
- indirectG = new float[width * height];
- indirectB = new float[width * height];
- estimateR = new float[width * height];
- estimateG = new float[width * height];
- estimateB = new float[width * height];
- java.util.Arrays.fill(estimateR, (float) INITIAL_IRRADIANCE);
- 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;
for (int j = 0; j < height; j++)
System.arraycopy(valid, 0, validTexels, 0, count);
// Both buffers start at the uniform medium INITIAL_IRRADIANCE:
- // the world is visible from frame one and fades toward the traced
- // solution (lit areas brighten, unlit areas sink to darkness).
+ // the world is visible from frame one; each lightmap flips to
+ // its traced lighting when the bake reaches it.
buffers[0] = createTexture();
buffers[1] = createTexture();
}
final double dz = pz - originZ;
final double d11 = edge1X * edge1X + edge1Y * edge1Y + edge1Z * edge1Z;
final double d22 = edge2X * edge2X + edge2Y * edge2Y + edge2Z * edge2Z;
- final double d12 = edge1X * edge2X + edge1Y * edge2Y + edge1Z * edge2Z;
+ final double d12 = edge1X * edge2X + edge1Y * edge2X + edge1Z * edge2Z;
final double dp1 = dx * edge1X + dy * edge1Y + dz * edge1Z;
final double dp2 = dx * edge2X + dy * edge2Y + dz * edge2Z;
final double denom = d11 * d22 - d12 * d12;
}
/**
- * Ensures the per-texel visibility array matches the light count.
- * Called from GI threads during sampling.
+ * Allocates the per-texel bake state (indirect total, both delta
+ * buffers, visibility) and re-arms the bake progression. Idempotent;
+ * called from GI threads when the bake reaches this lightmap, and
+ * again when a scene change restarts the bake after the arrays were
+ * freed. A bake restart over previously used arrays also zeroes the
+ * accumulated totals so the old solution cannot leak into the new
+ * one (the delta buffers need no clearing: generation 2 reads only
+ * visibility, and deltaPrev is rewritten by the generation-2
+ * rollover before generation 3 reads it).
*
* @param lights number of lights in the snapshot
*/
- public void ensureLightCapacity(final int lights) {
+ public void ensureArrays(final int lights) {
+ if (indirectR == null) {
+ synchronized (this) {
+ if (indirectR == null) {
+ final int n = width * height;
+ indirectR = new float[n];
+ indirectG = new float[n];
+ indirectB = new float[n];
+ deltaPrevR = new float[n];
+ deltaPrevG = new float[n];
+ deltaPrevB = new float[n];
+ deltaCurR = new float[n];
+ deltaCurG = new float[n];
+ deltaCurB = new float[n];
+ }
+ }
+ } else {
+ java.util.Arrays.fill(indirectR, 0f);
+ java.util.Arrays.fill(indirectG, 0f);
+ java.util.Arrays.fill(indirectB, 0f);
+ }
if (lightVisibility == null || lightCount != lights) {
lightVisibility = new byte[width * height * lights];
lightCount = lights;
}
+ generation = 0;
+ baked = false;
}
/**
- * 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
+ * Frees all per-texel bake state at the end of the global bake. The
+ * double-buffered composite textures stay — from here on they are
+ * the only per-texel state, and the displayed one is final.
*/
- 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;
+ public void freeArrays() {
+ indirectR = null;
+ indirectG = null;
+ indirectB = null;
+ deltaPrevR = null;
+ deltaPrevG = null;
+ deltaPrevB = null;
+ deltaCurR = null;
+ deltaCurG = null;
+ deltaCurB = null;
+ lightVisibility = null;
+ baked = true;
}
/**
- * 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.
+ * Generation barrier rollover: folds the finished generation's delta
+ * into the accumulated total, then makes it the new gather source
+ * (delta buffers swapped, the fresh one zeroed). Called by the GI
+ * system only, with no sampling in flight.
*
- * @param centroidVisibility per-light visibility bytes (length = lightCount)
+ * @return the generation's absolute per-texel delta summed over the
+ * valid texels (light units), the termination signal
*/
- 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();
+ public double rollover() {
+ double energy = 0;
+ for (final int t : validTexels) {
+ final float dR = deltaCurR[t];
+ final float dG = deltaCurG[t];
+ final float dB = deltaCurB[t];
+ indirectR[t] += dR;
+ indirectG[t] += dG;
+ indirectB[t] += dB;
+ energy += Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB)));
}
- }
-
- /**
- * 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);
+ float[] swap;
+ swap = deltaPrevR; deltaPrevR = deltaCurR; deltaCurR = swap;
+ swap = deltaPrevG; deltaPrevG = deltaCurG; deltaCurG = swap;
+ swap = deltaPrevB; deltaPrevB = deltaCurB; deltaCurB = swap;
+ java.util.Arrays.fill(deltaCurR, 0f);
+ java.util.Arrays.fill(deltaCurG, 0f);
+ java.util.Arrays.fill(deltaCurB, 0f);
+ generation++;
+ return energy;
}
/**
/** 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. */
+ /** Squared distance from the camera at snapshot build (near-to-far bake order). */
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;
final Point2D projectedPoint3, final double scaleFactor,
final Vertex v1, final Vertex v2, final Vertex v3) {
// SDF (text/decal) is alpha-class — it paints in the
- // back-to-front alpha pass only, without depth interaction.
+ // back-to-front alpha pass only: depth-TESTED against the
+ // opaque pass, never depth-written (same convention as
+ // translucent textures). Without the test, text panels draw
+ // on top of nearer geometry (bugreport-20261002-004736).
if (renderBuffer.depthPass == 1)
return;
// Per-axis screen-space UV gradients (affine estimate — adequate
double su1 = 0, sv1 = 0, sw1 = 0;
double su2 = 0, sv2 = 0, sw2 = 0;
double su3 = 0, sv3 = 0, sw3 = 0;
+ // Camera-space z feeds BOTH the perspective-selection test and
+ // the per-pixel depth test (zw = 1/z rides the same edge
+ // interpolation as the texture gradients, both span writers).
+ final double z1 = v1.transformedCoordinate(renderBuffer).z;
+ final double z2 = v2.transformedCoordinate(renderBuffer).z;
+ final double z3 = v3.transformedCoordinate(renderBuffer).z;
+ final double zw1 = 1d / z1;
+ final double zw2 = 1d / z2;
+ final double zw3 = 1d / z3;
if (perspectiveCorrectionEnabled) {
- final double z1 = v1.transformedCoordinate(renderBuffer).z;
- final double z2 = v2.transformedCoordinate(renderBuffer).z;
- final double z3 = v3.transformedCoordinate(renderBuffer).z;
if (z1 > PERSPECTIVE_MIN_Z && z2 > PERSPECTIVE_MIN_Z && z3 > PERSPECTIVE_MIN_Z) {
// Same affine-sufficiency test as the coverage path
// (mask/fg/bg are all primary resolution, mf = 1).
pi[0].setPoints(projectedPoint1, projectedPoint2, su1, sv1, sw1, su2, sv2, sw2);
pi[1].setPoints(projectedPoint1, projectedPoint3, su1, sv1, sw1, su3, sv3, sw3);
pi[2].setPoints(projectedPoint2, projectedPoint3, su2, sv2, sw2, su3, sv3, sw3);
+ // 1/z rides the same edge interpolation; the span depth-tests
+ // (never writes — alpha pass) before each pixel.
+ pi[0].setPointsZW(zw1, zw2);
+ pi[1].setPointsZW(zw1, zw3);
+ pi[2].setPointsZW(zw2, zw3);
for (int y = yTop; y <= yBottom; y++) {
if (pi[0].containsY(y)) {
pbi2.setPoints(projectedPoint1, projectedPoint3, v1.textureCoordinate, v3.textureCoordinate);
pbi3.setPoints(projectedPoint2, projectedPoint3, v2.textureCoordinate, v3.textureCoordinate);
+ // Depth test (no write) rides the same edge interpolation.
+ pbi1.setPointsZW(zw1, zw2);
+ pbi2.setPointsZW(zw1, zw3);
+ pbi3.setPointsZW(zw2, zw3);
+
for (int y = yTop; y <= yBottom; y++) {
if (pbi1.containsY(y)) {
if (pbi2.containsY(y))
int x1 = line1.getX();
int x2 = line2.getX();
- final double tx1, ty1, tx2, ty2;
+ final double tx1, ty1, zw1;
+ final double tx2, ty2, zw2;
if (x1 <= x2) {
tx1 = line1.getTX() * mf;
ty1 = line1.getTY() * mf;
+ zw1 = line1.getZW();
tx2 = line2.getTX() * mf;
ty2 = line2.getTY() * mf;
+ zw2 = line2.getZW();
} else {
final int tmp = x1;
x1 = x2;
x2 = tmp;
tx1 = line2.getTX() * mf;
ty1 = line2.getTY() * mf;
+ zw1 = line2.getZW();
tx2 = line1.getTX() * mf;
ty2 = line1.getTY() * mf;
+ zw2 = line1.getZW();
}
final double realWidth = x2 - x1;
int renderBufferOffset = (y * renderBuffer.width) + x1;
final int[] renderBufferPixels = renderBuffer.pixels;
+ final float[] depth = renderBuffer.depth;
final double txStep = (tx2 - tx1) / realWidth;
final double tyStep = (ty2 - ty1) / realWidth;
+ final double dzw = (zw2 - zw1) / realWidth;
double tx = tx1 + txStep * (x1 - realX1);
double ty = ty1 + tyStep * (x1 - realX1);
+ double zw = zw1 + dzw * (x1 - realX1);
final int[] maskPixels = mask.pixels;
final int[] fgPixels = fg.pixels;
final int mh1 = mh - 1;
for (int x = x1; x < x2; x++) {
- // Fixed-point bilinear distance fetch (8.8 fractions)
- final double ctx = tx < 0 ? 0 : Math.min(tx, bilinearCapX);
- final double cty = ty < 0 ? 0 : Math.min(ty, bilinearCapY);
- final int x0 = (int) ctx;
- final int y0 = (int) cty;
- final int fx = (int) ((ctx - x0) * 256);
- final int fy = (int) ((cty - y0) * 256);
- final int row0 = y0 * mw + x0;
- final int row1 = row0 + mw;
- final int m00 = (maskPixels[row0] >> 16) & 0xff;
- final int m10 = (maskPixels[row0 + 1] >> 16) & 0xff;
- final int m01 = (maskPixels[row1] >> 16) & 0xff;
- final int m11 = (maskPixels[row1 + 1] >> 16) & 0xff;
- final int d = (m00 * (256 - fx) * (256 - fy) + m10 * fx * (256 - fy)
- + m01 * (256 - fx) * fy + m11 * fx * fy) >> 16;
-
- int cov = (int) ((127.5d - d) * aaK + 128d);
- if (cov < 0) cov = 0;
- else if (cov > 256) cov = 256;
- if (covLut != null) cov = covLut[cov];
-
- int itx = (int) tx;
- int ity = (int) ty;
- if (itx < 0) itx = 0;
- else if (itx > mw1) itx = mw1;
- if (ity < 0) ity = 0;
- else if (ity > mh1) ity = mh1;
- final int addr = ity * mw + itx;
-
- final int srcPixel;
- if (cov <= 0) {
- srcPixel = bgPixels[addr];
- } else if (cov >= 256) {
- srcPixel = fgPixels[addr];
- } else {
- final int bgP = bgPixels[addr];
- final int fgP = fgPixels[addr];
- final int a = (bgP >>> 24) + ((((int) (fgP >>> 24) - (bgP >>> 24)) * cov) >> 8);
- final int r = ((bgP >> 16) & 0xff) + (((((fgP >> 16) & 0xff) - ((bgP >> 16) & 0xff)) * cov) >> 8);
- final int g = ((bgP >> 8) & 0xff) + (((((fgP >> 8) & 0xff) - ((bgP >> 8) & 0xff)) * cov) >> 8);
- final int b = (bgP & 0xff) + ((((fgP & 0xff) - (bgP & 0xff)) * cov) >> 8);
- srcPixel = (a << 24) | (r << 16) | (g << 8) | b;
- }
+ // Depth test only — SDF is alpha-class and never writes
+ // depth (paintSdf returns early in the opaque pass).
+ if (zw > depth[renderBufferOffset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+ // Fixed-point bilinear distance fetch (8.8 fractions)
+ final double ctx = tx < 0 ? 0 : Math.min(tx, bilinearCapX);
+ final double cty = ty < 0 ? 0 : Math.min(ty, bilinearCapY);
+ final int x0 = (int) ctx;
+ final int y0 = (int) cty;
+ final int fx = (int) ((ctx - x0) * 256);
+ final int fy = (int) ((cty - y0) * 256);
+ final int row0 = y0 * mw + x0;
+ final int row1 = row0 + mw;
+ final int m00 = (maskPixels[row0] >> 16) & 0xff;
+ final int m10 = (maskPixels[row0 + 1] >> 16) & 0xff;
+ final int m01 = (maskPixels[row1] >> 16) & 0xff;
+ final int m11 = (maskPixels[row1 + 1] >> 16) & 0xff;
+ final int d = (m00 * (256 - fx) * (256 - fy) + m10 * fx * (256 - fy)
+ + m01 * (256 - fx) * fy + m11 * fx * fy) >> 16;
+
+ int cov = (int) ((127.5d - d) * aaK + 128d);
+ if (cov < 0) cov = 0;
+ else if (cov > 256) cov = 256;
+ if (covLut != null) cov = covLut[cov];
- final int srcAlpha = (srcPixel >> 24) & 0xff;
- if (srcAlpha == 255) {
- renderBufferPixels[renderBufferOffset] = srcPixel;
- } else if (srcAlpha != 0) {
- final int destPixel = renderBufferPixels[renderBufferOffset];
- final int destR = (destPixel >> 16) & 0xff;
- final int destG = (destPixel >> 8) & 0xff;
- final int destB = destPixel & 0xff;
- final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8);
- final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8);
- final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8);
- renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+ int itx = (int) tx;
+ int ity = (int) ty;
+ if (itx < 0) itx = 0;
+ else if (itx > mw1) itx = mw1;
+ if (ity < 0) ity = 0;
+ else if (ity > mh1) ity = mh1;
+ final int addr = ity * mw + itx;
+
+ final int srcPixel;
+ if (cov <= 0) {
+ srcPixel = bgPixels[addr];
+ } else if (cov >= 256) {
+ srcPixel = fgPixels[addr];
+ } else {
+ final int bgP = bgPixels[addr];
+ final int fgP = fgPixels[addr];
+ final int a = (bgP >>> 24) + ((((int) (fgP >>> 24) - (bgP >>> 24)) * cov) >> 8);
+ final int r = ((bgP >> 16) & 0xff) + (((((fgP >> 16) & 0xff) - ((bgP >> 16) & 0xff)) * cov) >> 8);
+ final int g = ((bgP >> 8) & 0xff) + (((((fgP >> 8) & 0xff) - ((bgP >> 8) & 0xff)) * cov) >> 8);
+ final int b = (bgP & 0xff) + ((((fgP & 0xff) - (bgP & 0xff)) * cov) >> 8);
+ srcPixel = (a << 24) | (r << 16) | (g << 8) | b;
+ }
+
+ final int srcAlpha = (srcPixel >> 24) & 0xff;
+ if (srcAlpha == 255) {
+ renderBufferPixels[renderBufferOffset] = srcPixel;
+ } else if (srcAlpha != 0) {
+ final int destPixel = renderBufferPixels[renderBufferOffset];
+ final int destR = (destPixel >> 16) & 0xff;
+ final int destG = (destPixel >> 8) & 0xff;
+ final int destB = destPixel & 0xff;
+ final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8);
+ final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8);
+ final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8);
+ renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+ }
}
tx += txStep;
ty += tyStep;
+ zw += dzw;
renderBufferOffset++;
}
}
int x1 = line1.getX();
int x2 = line2.getX();
- final double su1, sv1, sw1;
- final double su2, sv2, sw2;
+ final double su1, sv1, sw1, zw1;
+ final double su2, sv2, sw2, zw2;
if (x1 <= x2) {
su1 = line1.getSU();
sv1 = line1.getSV();
sw1 = line1.getSW();
+ zw1 = line1.getZW();
su2 = line2.getSU();
sv2 = line2.getSV();
sw2 = line2.getSW();
+ zw2 = line2.getZW();
} else {
final int tmp = x1;
x1 = x2;
su1 = line2.getSU();
sv1 = line2.getSV();
sw1 = line2.getSW();
+ zw1 = line2.getZW();
su2 = line1.getSU();
sv2 = line1.getSV();
sw2 = line1.getSW();
+ zw2 = line1.getZW();
}
final double realWidth = x2 - x1;
final double dsu = (su2 - su1) / realWidth;
final double dsv = (sv2 - sv1) / realWidth;
final double dsw = (sw2 - sw1) / realWidth;
+ final double dzw = (zw2 - zw1) / realWidth;
double su = su1 + dsu * (x1 - realX1);
double sv = sv1 + dsv * (x1 - realX1);
double sw = sw1 + dsw * (x1 - realX1);
+ double zw = zw1 + dzw * (x1 - realX1);
final int[] renderBufferPixels = renderBuffer.pixels;
+ final float[] depth = renderBuffer.depth;
final int[] maskPixels = mask.pixels;
final int[] fgPixels = fg.pixels;
final double tyStep = (tyNext - ty) * invBlock;
for (int i = 0; i < block; i++) {
- // Fixed-point bilinear distance fetch (8.8 fractions)
- final double ctx = tx < 0 ? 0 : Math.min(tx, bilinearCapX);
- final double cty = ty < 0 ? 0 : Math.min(ty, bilinearCapY);
- final int x0 = (int) ctx;
- final int y0 = (int) cty;
- final int fx = (int) ((ctx - x0) * 256);
- final int fy = (int) ((cty - y0) * 256);
- final int row0 = y0 * mw + x0;
- final int row1 = row0 + mw;
- final int m00 = (maskPixels[row0] >> 16) & 0xff;
- final int m10 = (maskPixels[row0 + 1] >> 16) & 0xff;
- final int m01 = (maskPixels[row1] >> 16) & 0xff;
- final int m11 = (maskPixels[row1 + 1] >> 16) & 0xff;
- final int d = (m00 * (256 - fx) * (256 - fy) + m10 * fx * (256 - fy)
- + m01 * (256 - fx) * fy + m11 * fx * fy) >> 16;
-
- int cov = (int) ((127.5d - d) * aaK + 128d);
- if (cov < 0) cov = 0;
- else if (cov > 256) cov = 256;
- if (covLut != null) cov = covLut[cov];
-
- int itx = (int) tx;
- int ity = (int) ty;
- if (itx < 0) itx = 0;
- else if (itx > mw1) itx = mw1;
- if (ity < 0) ity = 0;
- else if (ity > mh1) ity = mh1;
- final int addr = ity * mw + itx;
+ // Depth test only — SDF is alpha-class and never writes
+ // depth (paintSdf returns early in the opaque pass).
+ if (zw > depth[renderBufferOffset] - RenderingContext.DEPTH_MARGIN_DZ * zw * zw) {
+ // Fixed-point bilinear distance fetch (8.8 fractions)
+ final double ctx = tx < 0 ? 0 : Math.min(tx, bilinearCapX);
+ final double cty = ty < 0 ? 0 : Math.min(ty, bilinearCapY);
+ final int x0 = (int) ctx;
+ final int y0 = (int) cty;
+ final int fx = (int) ((ctx - x0) * 256);
+ final int fy = (int) ((cty - y0) * 256);
+ final int row0 = y0 * mw + x0;
+ final int row1 = row0 + mw;
+ final int m00 = (maskPixels[row0] >> 16) & 0xff;
+ final int m10 = (maskPixels[row0 + 1] >> 16) & 0xff;
+ final int m01 = (maskPixels[row1] >> 16) & 0xff;
+ final int m11 = (maskPixels[row1 + 1] >> 16) & 0xff;
+ final int d = (m00 * (256 - fx) * (256 - fy) + m10 * fx * (256 - fy)
+ + m01 * (256 - fx) * fy + m11 * fx * fy) >> 16;
+
+ int cov = (int) ((127.5d - d) * aaK + 128d);
+ if (cov < 0) cov = 0;
+ else if (cov > 256) cov = 256;
+ if (covLut != null) cov = covLut[cov];
- final int srcPixel;
- if (cov <= 0) {
- srcPixel = bgPixels[addr];
- } else if (cov >= 256) {
- srcPixel = fgPixels[addr];
- } else {
- final int bgP = bgPixels[addr];
- final int fgP = fgPixels[addr];
- final int a = (bgP >>> 24) + ((((int) (fgP >>> 24) - (bgP >>> 24)) * cov) >> 8);
- final int r = ((bgP >> 16) & 0xff) + (((((fgP >> 16) & 0xff) - ((bgP >> 16) & 0xff)) * cov) >> 8);
- final int g = ((bgP >> 8) & 0xff) + (((((fgP >> 8) & 0xff) - ((bgP >> 8) & 0xff)) * cov) >> 8);
- final int b = (bgP & 0xff) + ((((fgP & 0xff) - (bgP & 0xff)) * cov) >> 8);
- srcPixel = (a << 24) | (r << 16) | (g << 8) | b;
- }
+ int itx = (int) tx;
+ int ity = (int) ty;
+ if (itx < 0) itx = 0;
+ else if (itx > mw1) itx = mw1;
+ if (ity < 0) ity = 0;
+ else if (ity > mh1) ity = mh1;
+ final int addr = ity * mw + itx;
+
+ final int srcPixel;
+ if (cov <= 0) {
+ srcPixel = bgPixels[addr];
+ } else if (cov >= 256) {
+ srcPixel = fgPixels[addr];
+ } else {
+ final int bgP = bgPixels[addr];
+ final int fgP = fgPixels[addr];
+ final int a = (bgP >>> 24) + ((((int) (fgP >>> 24) - (bgP >>> 24)) * cov) >> 8);
+ final int r = ((bgP >> 16) & 0xff) + (((((fgP >> 16) & 0xff) - ((bgP >> 16) & 0xff)) * cov) >> 8);
+ final int g = ((bgP >> 8) & 0xff) + (((((fgP >> 8) & 0xff) - ((bgP >> 8) & 0xff)) * cov) >> 8);
+ final int b = (bgP & 0xff) + ((((fgP & 0xff) - (bgP & 0xff)) * cov) >> 8);
+ srcPixel = (a << 24) | (r << 16) | (g << 8) | b;
+ }
- final int srcAlpha = (srcPixel >> 24) & 0xff;
- if (srcAlpha == 255) {
- renderBufferPixels[renderBufferOffset] = srcPixel;
- } else if (srcAlpha != 0) {
- final int destPixel = renderBufferPixels[renderBufferOffset];
- final int destR = (destPixel >> 16) & 0xff;
- final int destG = (destPixel >> 8) & 0xff;
- final int destB = destPixel & 0xff;
- final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8);
- final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8);
- final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8);
- renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+ final int srcAlpha = (srcPixel >> 24) & 0xff;
+ if (srcAlpha == 255) {
+ renderBufferPixels[renderBufferOffset] = srcPixel;
+ } else if (srcAlpha != 0) {
+ final int destPixel = renderBufferPixels[renderBufferOffset];
+ final int destR = (destPixel >> 16) & 0xff;
+ final int destG = (destPixel >> 8) & 0xff;
+ final int destB = destPixel & 0xff;
+ final int r = destR + ((srcAlpha * (((srcPixel >> 16) & 0xff) - destR) - destR) >> 8);
+ final int g = destG + ((srcAlpha * (((srcPixel >> 8) & 0xff) - destG) - destG) >> 8);
+ final int b = destB + ((srcAlpha * ((srcPixel & 0xff) - destB) - destB) >> 8);
+ renderBufferPixels[renderBufferOffset] = (r << 16) | (g << 8) | b;
+ }
}
tx += txStep;
ty += tyStep;
+ zw += dzw;
renderBufferOffset++;
}
+# 3Dconnexion SpaceNavigator (USB)
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c626", MODE="0666"
+# 3Dconnexion SpaceMouse Wireless (Bluetooth) — enumerates via uhid, so
+# the parent chain has no idVendor/idProduct and udev does not import
+# HID_ID into the hidraw node; match the parent hid device's kernel name.
+SUBSYSTEM=="hidraw", KERNELS=="0005:256F:C63A.*", MODE="0666"