From: Svjatoslav Agejenko Date: Fri, 2 Oct 2026 19:13:00 +0000 (+0300) Subject: feat: bounce-generation GI bake and SpaceMouse Wireless BT support X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;p=aukio-3d.git feat: bounce-generation GI bake and SpaceMouse Wireless BT support Global illumination is rebuilt around strict bounce generations instead of the phased scheduler: generation 1 caches per-texel light visibility (direct light is always derived from it, never stored), generation k gathers the hit surface's generation-(k-1) delta so each generation adds exactly one bounce, and a global barrier freezes the field within a generation (plain running-mean sampling, no EMAs). The bake terminates on a mean-delta threshold or the e3d.gi.generations cap, and all per-texel arrays (indirect totals, delta buffers, visibility) are freed when the bake completes — a finished lightmap keeps only its final composite texture. The phased mode's knobs (phases, activeWindow, resortDistance, freezeThreshold, alphaMode, compositeAlpha, calmThreshold) are gone, replaced by generations, samples and deltaThreshold. SpaceNavigatorHid now supports multiple 3Dconnexion models behind a Model enum: SpaceNavigator over USB and SpaceMouse Wireless over Bluetooth, which sends one combined 13-byte six-axis report, a button report and a battery report (charge 0..100 plus charging flag). The hot-plug manager prints the detected model. The udev rules gain a KERNELS match for the Bluetooth model, which enumerates via uhid so idVendor/idProduct attributes do not exist. Docs (GI pipeline and SpaceMouse manuals, index, AGENTS.org) are rewritten to match; ViewPanel drops unused head-tracking imports. --- diff --git a/AGENTS.org b/AGENTS.org index e774822..ca6f07e 100644 --- a/AGENTS.org +++ b/AGENTS.org @@ -574,7 +574,7 @@ rebuild the exact demo scene headlessly. | ~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 | diff --git a/Documentation/Global illumination/GI pipeline.svg b/Documentation/Global illumination/GI pipeline.svg index 0f63023..5183dd3 100644 --- a/Documentation/Global illumination/GI pipeline.svg +++ b/Documentation/Global illumination/GI pipeline.svg @@ -21,8 +21,8 @@ GI workers - Monte Carlo - sweeps + bounce + generations lightmaps @@ -48,7 +48,7 @@ - bounce reads last sweep's estimate + generation k reads generation k-1 zero ray casting on render threads diff --git a/Documentation/Global illumination/index.org b/Documentation/Global illumination/index.org index c590258..5a818c0 100644 --- a/Documentation/Global illumination/index.org +++ b/Documentation/Global illumination/index.org @@ -25,15 +25,16 @@ dark. A room corner reads as a flat silhouette instead of a corner. #+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 @@ -49,11 +50,11 @@ so it doesn't: *the render loop never traces a single ray.* Painting a 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 @@ -78,146 +79,177 @@ texel region is the half where u+v <= 1; the other half of the square 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: @@ -236,7 +268,9 @@ interface that =GlobalIllumination= installs into the 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: @@ -253,13 +287,12 @@ triplet / 255 — per-triangle baked shading at a branch per pixel. 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 @@ -288,6 +321,34 @@ with the top's light): 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 @@ -308,38 +369,6 @@ model transforms into the vertex coordinates at load time (see 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 @@ -348,9 +377,13 @@ Tuning knobs (system properties): - *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 @@ -361,11 +394,11 @@ Tuning knobs (system properties): :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 || diff --git a/Documentation/SpaceMouse 6DOF/index.org b/Documentation/SpaceMouse 6DOF/index.org index 99f26ca..81e59b4 100644 --- a/Documentation/SpaceMouse 6DOF/index.org +++ b/Documentation/SpaceMouse 6DOF/index.org @@ -19,14 +19,16 @@ :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=. @@ -71,17 +73,25 @@ interchangeably mid-motion. :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/ @@ -92,16 +102,34 @@ The device is *silent at rest* — it only sends reports while the cap is deflected — so there is no liveness watchdog; an unplug is noticed on the next failed read. +* 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 diff --git a/Documentation/index.org b/Documentation/index.org index 1cc4461..699d2a4 100644 --- a/Documentation/index.org +++ b/Documentation/index.org @@ -106,9 +106,10 @@ section for the glasses IMU support. [[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. diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeviceHotplug.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeviceHotplug.java index ab8c26d..11eaec4 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeviceHotplug.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/DeviceHotplug.java @@ -11,7 +11,7 @@ import eu.svjatoslav.aukio.e3d.gui.spacemouse.SpaceNavigatorHid; /** * 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. * @@ -24,7 +24,7 @@ final class DeviceHotplug { /** 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; /** @@ -47,7 +47,7 @@ final class DeviceHotplug { } /** - * 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() { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java index 5dda998..daef0ad 100755 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/ViewPanel.java @@ -14,9 +14,7 @@ import eu.svjatoslav.aukio.e3d.diag.Diagnostics; 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; @@ -35,7 +33,6 @@ import java.util.Set; 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; /** @@ -1319,7 +1316,7 @@ public class ViewPanel extends Canvas { } /** - * 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() { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java index 1951873..a64b8e6 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseController.java @@ -12,7 +12,9 @@ import eu.svjatoslav.aukio.e3d.math.Quaternion; 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): * *
    *
  • push/pull/slide the cap → camera moves DIRECTLY, proportional diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java index d2a5cd9..20fd5b4 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceMouseManager.java @@ -7,8 +7,10 @@ package eu.svjatoslav.aukio.e3d.gui.spacemouse; 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: * *
      *
    • plugged in → open the HID pipe, attach a @@ -42,12 +44,12 @@ public final class SpaceMouseManager { 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; } @@ -94,8 +96,9 @@ public final class SpaceMouseManager { 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) { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java index 7709c5c..bff55af 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/gui/spacemouse/SpaceNavigatorHid.java @@ -11,14 +11,17 @@ import com.sun.jna.Native; 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. * - *

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

      + *

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

      * *
        *
      • report[0] = 1 — translation; int16 LE at [1,2]=X, [3,4]=Y, @@ -28,7 +31,21 @@ import java.io.File; *
      • report[0] = 3 — buttons; report[1] bit0 = left, bit1 = right
      • *
      * - *

      Raw axis sign conventions (SpaceNavigator hardware):

      + *

      Protocol, SpaceMouse Wireless over Bluetooth (from the device's + * own HID report descriptor): a single combined motion report plus + * separate button and battery reports:

      + * + *
        + *
      • 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)
      • + *
      • report[0] = 3 — buttons; report[1] bit0 = left, bit1 = right + * (3 bytes total)
      • + *
      • report[0] = 0x17 — battery; report[1] = charge 0..100, + * report[2] bit0 = charging
      • + *
      + * + *

      Raw axis sign conventions (identical on both models):

      *
        *
      • TX: push cap right → positive
      • *
      • TY: pull cap toward you → positive (live-verified)
      • @@ -38,14 +55,45 @@ import java.io.File; *
      • RZ: twist cap clockwise (seen from above) → positive
      • *
      * - *

      Detection scans /sys/class/hidraw for HID_ID - * 0003:0000046D:0000C626 — the hidraw node changes on replug, so never + *

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

      */ 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; @@ -70,6 +118,7 @@ public final class SpaceNavigatorHid { } private final File deviceNode; + private final Model model; private final Memory readBuffer = new Memory(64); private int fd = -1; @@ -79,16 +128,20 @@ public final class SpaceNavigatorHid { /** 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). */ @@ -104,14 +157,16 @@ public final class SpaceNavigatorHid { 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()); } @@ -163,20 +218,45 @@ public final class SpaceNavigatorHid { 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; } } @@ -185,6 +265,11 @@ public final class SpaceNavigatorHid { 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; } @@ -193,6 +278,15 @@ public final class SpaceNavigatorHid { 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 diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java index de6bf25..c4c8131 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java @@ -16,7 +16,6 @@ import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.base.AbstractCom import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; import java.util.ArrayList; -import java.util.Arrays; import java.util.Comparator; import java.util.IdentityHashMap; import java.util.List; @@ -26,84 +25,66 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; /** - * Progressive CPU global illumination, running on dedicated low-priority - * threads (never on the render ForkJoinPool). + * CPU global illumination baked in strict bounce generations, running on + * dedicated low-priority threads (never on the render ForkJoinPool). * - *

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

      + *

      Generations: the scene is lit generation by generation, each + * walking all snapshot entries near-to-far from the camera, with a + * global barrier between generations:

      *
        - *
      1. A — centroid direct: each polygon gets one direct-light - * evaluation at its centroid (shadow rays to all lights). The - * result is stamped onto the whole polygon at once — the world - * lights up polygon by polygon as a visible near-to-far wave - * instead of staying at the uniform medium start until a full - * per-texel sweep completes (which does not scale to huge - * scenes).
      2. - *
      3. B — centroid multi-bounce: bounce rays from centroids, - * still one sample point per polygon, feeding the indirect field. - * Cheap enough to converge for the whole scene before any texel - * work starts.
      4. - *
      5. C — per-texel refinement: lightmapped triangles are - * refined near-to-far; a triangle entering this phase has its - * texels seeded from its converged centroid values, so detail - * glides in without a second dark age. A lightmap graduates once - * every texel has been re-sampled at least once — so no - * centroid-seeded shadow value survives — and its on-screen - * estimate has stopped moving; ray sampling then stops and a - * freeze tail keeps recompositing the now-fixed target until the - * on-screen estimate has fully glided in — without the tail, - * graduation would freeze a few light units of residual - * per-triangle stamp bias into the texture forever, visible as - * seams between adjacent triangles. When every lightmap has - * display-frozen, the workers idle.
      6. + *
      7. Generation 1 — direct: 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.
      8. + *
      9. Generation k >= 2 — one more bounce: 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.
      10. *
      * - *

      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.

      + *

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

      + * + *

      Storage lifetime: per-texel arrays (indirect total, two + * delta buffers, visibility) are allocated lazily when the bake reaches + * a lightmap and freed for the whole scene when the bake + * completes — 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.

      * *

      Two sampling resolutions:

      *
        *
      • Lightmapped triangles ({@link LightmappedShape}, e.g. the * wrapped polygons of a lightmapping-enabled * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape}): - * per-texel sampling 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.
      • - *
      • Plain solid polygons: per-polygon sampling (complete after - * phase B); the result feeds the flat-shading path through - * {@link GiLightProvider} (shadow tests + indirect add). Polygons - * stay single-colored.
      • + * GI threads every 500 ms during the bake and swapped in + * double-buffered — painters never see a half-updated texture. + *
      • Plain solid polygons: per-polygon sampling; the result + * feeds the flat-shading path through {@link GiLightProvider} + * (shadow tests + indirect add). Polygons stay single-colored.
      • *
      • Mesh blocks ({@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).
      • + * 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. *
      * - *

      Estimator: each bounce sample casts one cosine-weighted - * hemisphere ray from the surface. At the hit it evaluates direct light - * with cached shadow tests (next-event estimation) plus the hit - * surface's current indirect estimate, so bounce light propagates deeper - * over sweeps without an explicit recursion limit. In phase C two nested - * exponential moving averages shape what the user sees: the inner - * per-sample EMA smooths Monte Carlo noise in the indirect term, and the - * outer per-composite-update EMA wraps the complete sum - * ambient+direct+indirect with a fixed alpha, so refinement glides — - * no black-to-lit flash or shadow pop after the deliberate phase-A - * stamp. When converged, workers idle at a low cadence instead of - * burning CPU.

      - * *

      Render-side cost: zero ray casting. Lightmapped triangles * paint from their current composite texture; the flat-shading path only * reads cached per-polygon values. Frame rate is unaffected by GI @@ -134,12 +115,9 @@ public class GlobalIllumination implements GiLightProvider { /** 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; @@ -153,78 +131,34 @@ public class GlobalIllumination implements GiLightProvider { 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 @@ -235,22 +169,10 @@ public class GlobalIllumination implements GiLightProvider { 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 cameraPosition; private final List threads = new ArrayList<>(); @@ -260,7 +182,7 @@ public class GlobalIllumination implements GiLightProvider { 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 states = new ConcurrentHashMap<>(); private int lastSeenRenderListVersion = -1; @@ -270,34 +192,11 @@ public class GlobalIllumination implements GiLightProvider { /** 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 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 entries; @@ -305,35 +204,32 @@ public class GlobalIllumination implements GiLightProvider { List lights; IdentityHashMap 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 lightmaps; - /** Phased mode: all entries sorted near-to-far from the camera. */ - TriangleBvh.Entry[] sortedByDistance; - /** Phased mode: lightmapped entries only, sorted near-to-far. */ - TriangleBvh.Entry[] lightmappedSorted; - /** Phased mode: lightmap back to its snapshot entry (phase C graduation). */ - IdentityHashMap lightmapEntry; - /** Phase C lightmaps that graduated sampling but have not display-frozen yet. */ - int tailCount; - } - - /** One unit of 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) } /** @@ -348,7 +244,7 @@ public class GlobalIllumination implements GiLightProvider { /** * Creates the GI system. Call {@link #start()} to begin tracing. - * Distance ordering is disabled (uniform order, no camera re-sort). + * Distance ordering is disabled (uniform order). * * @param shapes the scene to trace * @param lightingManager the lights to sample @@ -366,8 +262,8 @@ public class GlobalIllumination implements GiLightProvider { * @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, @@ -414,32 +310,26 @@ public class GlobalIllumination implements GiLightProvider { } /** - * 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(); } // ------------------------------------------------------------------ @@ -482,6 +372,7 @@ public class GlobalIllumination implements GiLightProvider { 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(); @@ -491,11 +382,24 @@ public class GlobalIllumination implements GiLightProvider { 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 @@ -505,32 +409,6 @@ public class GlobalIllumination implements GiLightProvider { 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) { @@ -544,363 +422,59 @@ public class GlobalIllumination implements GiLightProvider { } } - /** 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]; @@ -908,11 +482,8 @@ public class GlobalIllumination implements GiLightProvider { 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); @@ -936,57 +507,65 @@ public class GlobalIllumination implements GiLightProvider { 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]; @@ -994,91 +573,173 @@ public class GlobalIllumination implements GiLightProvider { 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, @@ -1104,9 +765,8 @@ public class GlobalIllumination implements GiLightProvider { /** * 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; @@ -1172,131 +832,56 @@ public class GlobalIllumination implements GiLightProvider { // ------------------------------------------------------------------ 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]; @@ -1328,15 +913,15 @@ public class GlobalIllumination implements GiLightProvider { // 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) @@ -1367,27 +952,13 @@ public class GlobalIllumination implements GiLightProvider { } } - // 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(); @@ -1399,7 +970,6 @@ public class GlobalIllumination implements GiLightProvider { // 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"); @@ -1497,8 +1067,7 @@ public class GlobalIllumination implements GiLightProvider { if (DEBUG) System.out.println("[GI] rebuild trigger: renderListVersion " + lastSeenRenderListVersion + " -> " + version - + ", lightSignature " + lastLightSignature + " -> " + lightSignature - + ", phase was " + phase); + + ", lightSignature " + lastLightSignature + " -> " + lightSignature); final List triangles = new ArrayList<>(); shapes.collectRenderTriangles(triangles); @@ -1509,8 +1078,8 @@ public class GlobalIllumination implements GiLightProvider { // 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; @@ -1542,65 +1111,20 @@ public class GlobalIllumination implements GiLightProvider { 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 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; @@ -1608,19 +1132,11 @@ public class GlobalIllumination implements GiLightProvider { 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() { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java index dffa1a8..41f3973 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java @@ -20,25 +20,36 @@ import eu.svjatoslav.aukio.e3d.renderer.raster.texture.Texture; * smoothness comes from tracing at finer resolution (smaller * unitsPerTexel), never from upsampling.

      * - *

      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.

      + *

      Bake state: 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:

      + *
        + *
      • {@link #indirectR}/{@link #indirectG}/{@link #indirectB} — the + * accumulated indirect total (sum of all completed generations), + * the value the composite texture displays.
      • + *
      • {@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.
      • + *
      • {@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.
      • + *
      * - *

      Gradual convergence: 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.

      + *

      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 freed when the global bake + * completes — 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.

      * - *

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

      + *

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

      */ public class Lightmap { @@ -54,9 +65,10 @@ 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")); @@ -74,82 +86,55 @@ public class Lightmap { /** 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]; @@ -158,9 +143,6 @@ public class Lightmap { /** 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. * @@ -195,19 +177,6 @@ public class Lightmap { 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++) @@ -221,8 +190,8 @@ public class Lightmap { 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(); } @@ -272,7 +241,7 @@ public class Lightmap { 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; @@ -290,118 +259,95 @@ public class Lightmap { } /** - * 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; } /** diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java index 5211618..323a6fe 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java @@ -40,18 +40,8 @@ public class TriangleBvh { /** Unit surface normal, world space. */ public volatile float[] normal; - // --- Phased progressive GI bookkeeping (GI threads only) --- - - /** Squared distance from the camera at snapshot build / last re-sort. */ + /** 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; diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java index a6c2c38..06ae952 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java @@ -826,7 +826,10 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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 @@ -909,10 +912,16 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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). @@ -957,6 +966,11 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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)) { @@ -981,6 +995,11 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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)) @@ -1011,20 +1030,25 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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; @@ -1037,12 +1061,15 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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; @@ -1055,66 +1082,71 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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++; } } @@ -1138,16 +1170,18 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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; @@ -1155,9 +1189,11 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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; @@ -1177,12 +1213,15 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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; @@ -1229,66 +1268,71 @@ public class TexturedTriangle extends AbstractCoordinateShape { 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++; } diff --git a/udev/99-spacenavigator.rules b/udev/99-spacenavigator.rules index 524cfde..fa48ccd 100644 --- a/udev/99-spacenavigator.rules +++ b/udev/99-spacenavigator.rules @@ -1 +1,6 @@ +# 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"