Volumetric Blending Mode — Emission–Absorption Compositing

Status: RAY-MASS CONVENTION UNIFIED (2026-08-02) — supersedes the per-family chord factors described in the Phase 3 / Phase 4 blocks below.

τ = κ × the same ray mass that geometry’s ADDITIVE branch emits, for all three types. Gsplats already did this (tau = κ·uOpacity·intensity); points and lines additionally multiplied by a world thickness (vRadius·POINT_CHORD_SCALE / vWidthAtT·LINE_CHORD_SCALE). Both chord constants and their materials/{point,line}/math.ts modules are DELETED.

Why: a point’s opacity is a peak SCREEN ALPHA — already an integrated quantity — so the extra length read it as a volume density in volumetric and as a peak alpha in the other five modes. A Points node and its lift_points_to_gsplats twin therefore disagreed by exactly one path length (24× at R = 0.05, measured). The lift cannot fix that: a·σ·uRIF = opacity (additive) and a·σ·uRIF = opacity·R·chord (volumetric) share a left-hand side. Making every mode a functional of the ONE quantity the lift and coarse_substitutive_levels already conserve (render_light = Σ a·σ³) turns LOD consistency from a per-mode calibration into a structural property.

Consequences: κ is now dimensionless and comparable across points, lines, gsplats, scene scales and zoom levels — κ = 1 means “peak rendered alpha ≈ the authored per-element alpha” everywhere, so the per-layer κ slider track (absorptionBoundsForNode) is gone and one fixed 0.001–10 span serves every scene. Existing volumetric points/lines scenes need κ scaled DOWN by roughly 1/(thickness·0.826).

Shipped alongside an energy-preserving 2D dilation for gsplats (§3.1) — an independent defect that affected plain additive too.

Status: Phase 4 IMPLEMENTED (2026-07-24) — the plan is COMPLETE: all three geometry types render the real emission–absorption math on both shader backends. Lines compute the TRANSVERSE chord through the Gaussian-profile ribbon — rayMass = perpFalloff · vWidthAtT · LINE_CHORD_SCALE (= √(π/ln 100), same K = ln 100 truncation as points; derivation in rendering/materials/line/math.ts) — with τ = κ·density· rayMass where density collects the REMAINING intensity-chain factors (capFactor·edgeAA·widthScale·widthFade·nearFade·uOpacity; perpFalloff enters exactly once, via rayMass — the shader’s grouping is τ = κ·alpha· vWidthAtT·LINE_CHORD_SCALE with alpha = the additive-mode screen density, i.e. the full intensity chain × uOpacity), emission = gammaColor·alpha·S(τ) via the shared S(τ) series (materials/_shared/volumetric.ts), and output alpha = 1 − e^(−τ) over One/OneMinusSrcAlpha; κ = 0 is exactly additive. Lines also gained RGBA colors end-to-end ((N,3)→(N,4); Python writer channels=(3,4), the worker de-interleaves the alpha column through the existing interpolate_scalars_batch scalar kernel — NO WASM change; per-endpoint alphas ride texel5.zw, read through sanitizeAlpha and mixed along t, mapped through w(a) = −ln(1−a) under volumetric gated by uHasElementAlpha, plain linear scale in every other mode). The effectiveGeometryMode additive fallback is GONE — the helper became identity and was DELETED from rendering/blending-state.ts; lines volumetric depth-sorts via needsDepthSort(mode) with the existing lazy segment-midpoint provider, zero coordinator change. Both line materials carry absorption config / updateAbsorption / uAbsorption and updateHasElementAlpha / uHasElementAlpha (clone-carried); the layers panel κ slider now shows for lines too.

Status: Phase 3 IMPLEMENTED (2026-07-24): points render the real emission–absorption math. The point fragment computes the ISOTROPIC special case of the §3.1 ray integral — rayMass = falloff · R·√(π/K) (the line integral of the Gaussian-profile ball; K = ln 100 truncates at T = √(2K) ≈ 3.03σ, nearly the gsplat T = 3, so point and gsplat κ scales agree; POINT_CHORD_SCALE in rendering/materials/point/math.ts) — with τ = κ·density·rayMass where density = opacity·sizeScale²·nearFade (the profile enters τ exactly ONCE, via rayMass; every “how much of this point is there” factor scales τ, so fades leave no ghost fog). Points also gained RGBA colors: the alpha column rides texel2.y, active in every mode, mapped through w(a) under volumetric exactly like gsplats (§5.4.1), gated by uHasElementAlpha. effectiveGeometryMode at this point fell back to additive for LINES only (the helper became identity and was deleted when phase 4 shipped); the depth sort engages for points volumetric through the existing needsDepthSort(effectiveGeometryMode(...)) gates with zero coordinator change (the phase-B chokepoint pins inverted as designed). Showcase: the mandelbulb demo runs volumetric with full-strength colors (the ×0.1 anti-blowout color dimming is gone) at (κ, intensity) = (8, 0.5) — tuned at the demo’s full resolution, where a deep ray’s saturated radiance is ≈ c·intensity/(κ·radius·chord).

Status: Phase 2 IMPLEMENTED (2026-07-20): per-element opacity via the color ALPHA channel (RGBA colors) for gsplats — see §5.4.1. Alpha is active in EVERY blending mode (linear contribution scale; volumetric maps it into optical depth w = −ln(1−a)); the classical importer now stores learned 3DGS opacity in alpha (amplitudes := 1) so imported scenes occlude correctly. The original per-splat absorption_weights array plan is SUPERSEDED by this. Points/lines RGBA + volumetric were phases 3–4 (both since implemented — see the entries above).

Status: Phase 1 IMPLEMENTED (2026-07-19): gsplats + node-level κ, exactly per §4/§5 with the pre-implementation corrections below. Implementation deltas vs the text: (a) the layers-panel κ slider is additionally gated to gsplat/group layers (not just the volumetric mode) so points/lines never show a dead control; (b) LabeledSlider grew a setVisible() for the mode-conditional control; (c) both material clone()s round-trip absorption (the panel clones on ANY first interaction — a clone that reset κ to 1.0 was caught in review); (d) the S(τ) quotient divisor is guarded max(τ, 1e-20) on BOTH backends (GPU selects evaluate both lanes); (e) E2E I1 compares sampled pixels within one page session at per-sample tolerance ≤6/765 (TAA/dither headroom), not bit-exact screenshots. Phases 2–4 (per-splat weights, points, lines) remain open. Design settled 2026-07-19 (mode name, κ semantics, opacity-scales-density rule, per-splat weights spec’d-but-deferred, gsplats-first phasing). File/line references were verified against main 38c6eb19 at design time. Pre-implementation review corrections (2026-07-19): the TSL output branch is BUILD-TIME, so additive↔volumetric DOES require a graph rebuild (§5.4, risk #6); layer-state κ inits from the RAW attr (§5.5); phase-1 points/lines get an additive-state fallback (§5.1); the additive-ladder energy compensation does NOT apply to volumetric in phase 1 (§6); E2E expected blend state needs a per-geometry split (§8). Scope: A 6th blending mode, volumetric, spanning Python (enum, validation, node attr, default stamping), the viewer (mode SSOT, blend state, composition, shaders GLSL+TSL, depth-sort gating, layers-panel UI), and — in later phases, all since implemented — per-element alpha via RGBA colors and the Points/Lines geometry types. Goal: Physically grounded emission-with-occlusion rendering: each element adds its emitted light to the pixel AND exponentially attenuates everything behind it, composited back-to-front. One continuous knob (κ, absorption) spans the whole range from today’s additive (κ = 0, exactly) to a dense self-occluding medium. Non-goals: WebGPU compute sorting / order-independent transparency; scattering, shadowing, or any multi-bounce light transport; per-splat κ in phase 1 (spec’d in §5.4, built in phase 2); Points/Lines in phase 1 (phases 3–4, both since implemented); skipping the depth sort when κ = 0 (mode gates sorting — predicates stay simple).

Related reading: docs/guides/specs/GSPLAT_DEPTH_SORTING_SPEC.md (the sorting infrastructure this mode rides on), packages/luxar-viewer/src/rendering/README.md, packages/luxar-viewer/src/types/blending.ts (mode SSOT), docs/specs/GSPLATS_ZARR_FORMAT.md (format home of the deferred per-splat array).


1. Motivation and optical model

Luxar’s five blending modes currently occupy the two ends of the classical volume-rendering spectrum:

  • Pure emission — additive/luminous: every element adds light, nothing occludes. Commutative, unsorted, physically the κ → 0 limit of radiative transfer. The right model for sparse fluorescence, but dense scenes wash out: a bright background shines through foreground structure, and depth ordering is unreadable.

  • Pure occlusion — normal/opaque: front surfaces hide back ones (normal = premultiplied alpha-over with a clamped coverage alpha, sorted back-to-front; opaque = depth-writing alpha-over for Points/Lines and depth-tested overwrite for GSplats/Mesh, whose fragment alpha is 1). The right model for surfaces, but it discards the volumetric nature of the data — a splat is treated as a screen-aligned film, not a glowing medium with thickness.

The volumetric mode fills the middle with the standard emission–absorption model of direct volume rendering (Max 1995, Optical Models for Direct Volume Rendering): a medium with density ρ(x) both emits light proportionally to ρ and absorbs the light passing through it with optical cross-section κ per unit density. Along a view ray, the radiance reaching the camera is

L = ∫ ε(s) · T(s) ds,        T(s) = exp(−κ ∫₀ˢ ρ(u) du)

where ε ∝ ρ is emission and T is transmittance. This is exactly the model NeRF composites with (its per-sample weight is α = 1 − e^(−σδ)), and the model 3DGS approximates (§2). For Luxar it is unusually apt: fitted gsplat amplitudes are background-relative image intensities (proportional to the detected fluorescence after floor subtraction and normalisation — not a calibrated fluorophore concentration), not learned opacities — so κ has a physical reading (“effective turbidity of the sample”) rather than being a rendering hack.

What the user gets: one slider (κ) that morphs a layer continuously from X-ray-like additive glow (κ = 0 — bit-identical to today’s additive, §4.3 invariant I1) through attenuated projection (small κ: front structures pop, occluded ones dim — depth cueing for dense timelapses) to a dense smoke/ink-like medium (large κ). Together with max (MIP) and normal/opaque (surface), Luxar then covers every classical volume-rendering regime.

1.1 Mode taxonomy after the change

mode

alpha source

emission

projection

commutative

sorted

depthWrite

additive

— (One/One, α ignored)

ray integral

sum

yes

no

never

luminous

— (One/One, α ignored)

ray integral

sum

yes

no

never

max

— (MaxEquation)

peak value

peak

yes

no

never

volumetric

physics: 1 − e^(−τ), τ = κ·∫ρ

ray integral × screening

sum

no

yes

never

normal

clamped coverage: min(intensity·opacity, 1)

peak value

peak

no

yes

gsplats & points never; lines at opacity ≥ 0.99

opaque

Points/Lines: intensity·opacity; GSplats/Mesh: 1 (overwrite identity)

peak value

peak

no (depth-tested)

no (z-buffer; Point/Line α < 1 remains intra-draw order-dependent)

always

volumetric deliberately breaks the previous alignment sum-projection ⇒ commutative ⇒ unsorted: it is emissive for the projection taxonomy (usesPeakProjection(mode) === false, unchanged — rendering/blending-state.ts:93) but ordered for compositing. That split is why §5.2 introduces a needsDepthSort(mode) predicate distinct from usesPeakProjection.


2. Relation to NeRF and 3DGS (facts, to prevent drift)

All three — Luxar normal, 3DGS, and volumetric — use the same compositing operator: back-to-front premultiplied “over”, framebuffer state One / OneMinusSrcAlpha. They differ only in where alpha comes from:

  • NeRF: α = 1 − e^(−σδ) — the exact emission–absorption weight. volumetric is this, with the integral in closed form for Gaussians (§3).

  • 3DGS: α = o·G₂D(x) with a learned per-splat opacity o, clamped ≈ 0.99 — a heuristic that saturates by clamping instead of exponentially.

  • Luxar normal (shader-glsl.ts:436-445): RGB carries the full unclamped HDR contribution, alpha carries clamp(intensity·opacity, 0, 1) — an emitter-with-occlusion model that equals 3DGS compositing bit-for-bit in the LDR regime (intensity·opacity ≤ 1) and diverges above it, where 3DGS clamps emission and occlusion together and Luxar lets emission keep going.

So volumetric sits at the NeRF/radiative-transfer end: alpha derived from ray-integrated optical depth, saturating exponentially, view- and orientation-consistent (an elongated splat seen end-on absorbs more than seen side-on — a stored per-splat alpha cannot express that).


3. Mathematics

3.1 Per-splat quantities (gsplats)

For splat i with amplitude Aᵢ, covariance Σᵢ, and a pixel ray with unit direction r, the fragment shader’s sum-projection path already computes the exact line integral of the anisotropic 3D Gaussian (rendering/materials/gsplat/shader-glsl.ts:229-269):

σ_ray = 1 / sqrt(rᵀ Σ⁻¹ r)                       (shader-glsl.ts:265)
m(x)  = Aᵢ · G₂D(x) · σ_ray · c_T                 ("ray mass" at pixel x)

where G₂D is the shifted-truncated 2D Gaussian evaluated per fragment and c_T is the truncated-Gaussian integral factor √(2π)·erf(T/√2) − 2T·exp(−½T²) ≈ 2.433 at T = 3, precomputed by computeRayIntegralFactor (rendering/materials/gsplat/math.ts:35-49). m(x) is exactly what the additive fragment emits today (times color and opacity). Volumetric mode reuses it verbatim and adds:

τ(x)      = κ_eff · m(x)                              optical depth
T(x)      = exp(−τ)                                   transmittance
α(x)      = 1 − exp(−τ)                               absorption alpha
S(τ)      = (1 − exp(−τ)) / τ                         self-screening, S(0) = 1
emission  = color · m(x) · S(τ)                       self-absorbed emission

with the effective absorption coefficient

κ_eff = absorption (composed, §4.2) · opacity (composed) · wᵢ (per-splat weight, §5.4; 1 in phase 1)

and the fragment output

fragColor = vec4(emission, α)        blended One / OneMinusSrcAlpha, back-to-front.

Why opacity multiplies τ too: opacity means “how much of this layer is there” — it scales the density, hence emission and absorption together. Emission also carries the plain opacity factor it has today (m(x) enters the emission via the existing finalColor = gammaColor · intensity · uOpacity path, shader-glsl.ts:434). If opacity scaled only emission, fading a layer to 0 would leave an invisible fog that still darkens everything behind it; with density scaling, opacity → 0 removes both glow and occlusion, so layer fades and LOD cross-fades stay well defined. Unlike normal mode there is no depthWrite cliff at opacity ≥ 0.99 (normalModeDepthWrite, blending-state.ts:142) — everything is smooth in both sliders.

Why the screening factor S(τ): the front of a splat absorbs the emission of its own back. For emission and absorption both proportional to density the closed form of ∫ ε·T ds across one splat is exactly color · m · S(τ). Without it, a thick splat viewed end-on over-emits relative to the same mass split into thin splats, and invariant I2 below fails.

3.2 Numerical form

S(τ) = (1 − e^(−τ))/τ is 0/0 at τ = 0. Required implementation:

  • α via -expm1(-tau) in TS reference math (unit tests); both SHADER backends deliberately use the plain form for codegen bit-identity — GLSL/TSL 1.0 - exp(-tau) guarded by the branch below (float32 is adequate here — the error of 1-exp(-τ) at τ ≈ 1e-4 is ~1e-8, invisible at 8–10 bpc output).

  • S(τ): for τ < 1e-3 use the series S(τ) ≈ 1 − τ/2 + τ²/6 (relative error < 1e-10 at the cutoff); else α/τ. One branch, warp-coherent (τ varies smoothly per pixel).

  • κ = 0 must short-circuit to α = 0, S = 1 exactly (invariant I1) — the series gives this for free.

3.3 Invariants (testable laws)

  • I1 — additive limit: κ_eff = 0 ⇒ α = 0, S = 1, fragColor = vec4(emission, 0). Under One/OneMinusSrcAlpha, dst factor = 1 − 0 = 1 ⇒ identical framebuffer RGB arithmetic to additive’s One/One (the destination-ALPHA accumulation differs, invisible on the alpha:false canvas). A volumetric node at absorption 0 renders pixel-identical to the same node in additive mode (E2E pixel-compare test, §8).

  • I2 — split-splat multiplicativity: one splat with ray mass m ≡ its two ray-wise halves (mass m/2 each) composited back-to-front. Proof sketch: back half contributes L_b = (c/κ)(1 − e^(−κm/2)), T_b = e^(−κm/2); front over back gives L_f + T_f·L_b = (c/κ)(1 − e^(−κm)) = the whole splat. Exact — only because absorption is exponential and emission is screened. This is what keeps LOD merges/splits and the additive streaming ladder visually consistent, and it is the core unit test (§8).

  • I3 — order independence in the limit: as κ → 0 the compositing operator degenerates continuously to commutative addition; artifacts from an imperfect sort vanish proportionally to κ.


4. Format and Python API

4.1 Enum, validation, node property

  • BlendingMode gains VOLUMETRIC = "volumetric" (packages/luxar/src/luxar/typing_utils/enums.py:36-40); the depth-behavior docstring (L20-30) gains one line (never depth-writes, requires sorting). validate_blending_mode (validation/types.py:408-437) derives its set from the enum and updates automatically.

  • New validate_absorption modeled on validate_opacity (validation/types.py:257-282): float coercion, range [0, ∞) (no upper bound — κ is a physical coefficient; NaN/Inf rejected).

  • New Node.absorption property modeled on Node.opacity (core/node/node.py:552-574): getter self.attrs.get("absorption", 1.0), setter validates then self._persist_attr("absorption", ...).

4.2 Default stamping and composition

absorption is identity-valued (multiplicative identity 1.0) — per the blending-modes campaign doctrine it is therefore safe to default-stamp, following the opacity precedent exactly:

  • Stamp absorption = 1.0 where absent in apply_default_render_attrs (io/_compiler/node_common.py:56-63) and the gsplat twin apply_gsplat_group_attrs (io/_compiler/gsplat_assembly.py:430-438). (Contrast: blending_mode has no identity value and is deliberately never stamped — node_common.py:46-52.)

  • Viewer composition (§5.3): multiplicative down the chain like opacity/gamma/intensity — ancestors scale it, κ = 0 at any level zeroes absorption for the subtree.

  • Default 1.0 (not 0) so that switching a layer to volumetric immediately looks volumetric; dragging the slider to 0 recovers the additive look.

4.3 Scope

κ is read only by the volumetric shader branch — inert in every other mode (precedent: coverage alpha only matters in normal; truncation_radius only for gsplats). Setting it on a group and A/B-flipping the mode between additive and volumetric preserves the tuning.


5. Viewer design

5.1 Mode SSOT and blend state

  • BLENDING_MODES tuple gains 'volumetric' (packages/luxar-viewer/src/types/blending.ts:22). The layers-panel dropdown (ui/layers/layer-controls.ts:208-213 iterates the tuple), normalizeBlendingMode (rendering/blending-state.ts:124-133), and TS-side validation all grow automatically — the campaign’s SSOT paying off.

  • New predicate isVolumetricMode beside the others (blending-state.ts:42-76).

  • getCompleteBlendingState (blending-state.ts:189-266) gains a volumetric branch: CustomBlending, AddEquation, blendSrc One, blendDst OneMinusSrcAlpha, transparent true, depthTest true, depthWrite false unconditionally (no normalModeDepthWrite coupling), shaderOutputMode: 'premultiplied-alpha'. This is the same framebuffer state as getGSplatNormalBlendingState (blending-state.ts:303-315) — share or generalize rather than duplicate, but note the semantic difference lives in the fragment shader, not the blend state.

  • usesPeakProjection (blending-state.ts:93-95) unchanged — volumetric is sum-projected. But the TSL rebuild boundary must NOT be keyed on usesPeakProjection alone: the fragment output branch is chosen at graph build time (a JS conditional on config.blendingMode, shader-tsl.ts (the build-time normal-mode output branch)), so an additive ↔ volumetric switch changes the graph even though the projection doesn’t. The rebuild predicate (material-tsl.ts (the applyBlendingMode rebuild predicate)) generalizes its premultChanged term to an outputBranchChanged term covering BOTH isNormalMode and isVolumetricMode crossings (§5.4).

  • Points/lines in phase 1 (HISTORICAL — superseded by phases 3–4): the shared mode tuple means the panel dropdown offers volumetric for every geometry type, and Python accepts it on any node. During phases 1–3, point/line materials intercepted it in applyBlendingMode and applied the additive state instead (the exact κ = 0 limit of volumetric), keeping the requested mode in userData.blendingMode — and stored scenes did upgrade automatically when phases 3–4 shipped the real math. The effectiveGeometryMode downgrade helper that encoded this fallback became identity once lines landed and was DELETED from rendering/blending-state.ts; all three geometry types now apply the real volumetric blend state and shader branch directly.

5.2 Depth-sort gating: needsDepthSort(mode)

New predicate in blending-state.ts:

needsDepthSort(mode) = isNormalMode(mode) || isVolumetricMode(mode)

replacing isNormalMode at every order-dependence gate:

  • rendering/depth-sort-coordinator.ts:242 (sort dispatch), :519 (renderOrder-bias clearing), :610-618 (noteDepthSortBlendingModeSwitch transition logic — switching to a sorted mode clears committed data + reprocesses; switching away invalidates in-flight sorts). The additive↔volumetric transition thus reuses the exact machinery normal↔additive already exercises.

  • rendering/depth-sort-coordinator/render-order.ts — the cross-node renderOrder pass collects “normal-mode gsplat meshes” (docs L2-18, collection around L123/L166): volumetric meshes join the same global back-to-front domain.

  • The commit path (data/scene-loader/commit/commit-gsplats-geometry.ts:110-112) is deliberately un-gated (identity ordering is a no-op for commutative modes; the sort corrects ordered modes afterwards) — no change.

Picking: phase 1 keeps additive-style brightness-as-depth picking for volumetric (it is emissive; the setSurfacePickDepth(isNormalMode || isOpaqueMode) front-most rule in rendering/picking/picking-system.ts stays as-is). Front-most picking beyond a τ threshold is a possible follow-up, not phase 1.

Per-element alpha: one genuinely-shared rule, then two geometry-specific differences (phase-4 double-check review; each is a deliberate choice, not an oversight):

  • Invisible-but-pickable (shared across all three geometries): the pick shaders never read the per-element alpha (points texel2.y / lines texel5.zw / gsplat texel3.y) — pick salience (brightness-as-depth) derives from the coverage chain only. An element with alpha ≈ 0 is visually absent — emission scales to ~0, and under volumetric its optical depth w(a) = −ln(1 − a) vanishes with it, so it neither emits nor absorbs (the sole visible residue is the Lines normal-mode depth-write, third bullet) — yet remains fully pickable in every geometry type. Making picking alpha-aware would follow the same τ-threshold follow-up as front-most picking above.

  • Visual discard under volumetric (differs by geometry): POINTS and LINES do NOT discard a zero-alpha element while its color is non-black — a black-but-dense occluder keeps its τ, so the zero-color discard fires only when τ is negligible too. GSPLATS DO discard such an element: the gain-aware intensity discard folds alpha in first, so a ~zero-alpha splat neither emits nor absorbs and drops out in every mode.

  • normal-mode depthWrite keys on NODE opacity, never per-element alpha (differs by geometry): the normalModeDepthWrite predicate reads node opacity alone. Only LINES still use this predicate: an RGBA line node in normal mode at node-opacity ≥ 0.99 therefore writes depth even for its near-transparent (alpha ≈ 0) elements, which can occlude content behind them. POINTS now never depth-write in normal (like GSPLATS — getPointBlendingState forces it off, #1002), so this hazard applies to LINES only. Per-element depthWrite is not expressible in a single draw call; the workaround for lines is the volumetric mode itself (never depth-writes) or lowering node opacity below the 0.99 threshold.

5.3 Attr composition and the uniform

  • ComposableAttrs/EffectiveAttrs gain absorption (data/attrs-composer.ts:25-42); composeAttrs (L54-75) multiplies it like opacity/gamma/intensity (identity 1.0), clamped to max(0, ·). Consumers (data/scene-loader/view-state/effective-attrs.ts:25-38, the three loader-factory.ts sites at L191/258/321) thread it into material config.

  • New uniform uAbsorption following the uOpacity pattern end-to-end: GLSL rendering/materials/gsplat/material-glsl.ts (uniform type L111, init L172, setter L282), TSL material-tsl.ts (L92/128/287), material-manager config pass-through — the GSPLAT factory call only in phase 1 (rendering/material-manager.ts); the point/line factory configs deliberately omitted it until phases 3–4, and since phase 4 all three geometry factories thread absorption (every material owns uAbsorption + updateAbsorption).

5.4 Fragment shader (GLSL + TSL twins)

A third output branch beside LUXAR_NORMAL_PREMULT (shader-glsl.ts:436-451), guarded by a new define LUXAR_VOLUMETRIC:

#ifdef LUXAR_VOLUMETRIC
// 'volumetric' mode: emission–absorption (Max 1995). rayMass is the
// SUM-projection ray integral the additive path already computes; κ_eff
// couples the node absorption knob with opacity (density scaling).
float tau   = uAbsorption * uOpacity * rayMass;          // × wᵢ in phase 2
float alpha = 1.0 - exp(-tau);
float screen = (tau < 1e-3) ? 1.0 - 0.5*tau + tau*tau/6.0 : alpha / tau;
fragColor = vec4(finalColor * screen, alpha);
#endif

Key constraints:

  • The branch lives on the sum-projection vertex path (uProjectionMode = 0, shader-glsl.ts:222-273) — unlike LUXAR_NORMAL_PREMULT, which pairs with peak projection. applyBlendingMode (material-glsl.ts:450-510) manages the define + uProjectionMode + blend state per mode; the volumetric case sets LUXAR_VOLUMETRIC, projection 0, and the shared One/OneMinusSrcAlpha state.

  • finalColor already contains gammaColor · intensity · uOpacity (shader-glsl.ts:434) — i.e. emission’s density scaling by opacity is inherited; only τ needs the explicit uOpacity factor.

  • TSL twin: mirror as a build-time JS branch on config.blendingMode in shader-tsl.ts (the normal-mode output branch is the template; TSL .select() is deliberately avoided for structural branches because it materializes both sides), keeping 1:1 math with the GLSL. Because the branch is build-time, material-tsl.ts’s rebuild predicate must fire on any isVolumetricMode crossing:

    const outputBranchChanged =
      previousMode === undefined ||
      isNormalMode(previousMode) !== isNormalMode(mode) ||
      isVolumetricMode(previousMode) !== isVolumetricMode(mode);
    

    (replacing the old premultChanged; projectionChanged stays). Parity is enforced by tsl-shader-parity.spec.ts and the codegen snapshots (§8).

  • Discard interactions: the color discard (max(adjusted.rgb) < 1e-4 — the zero-color discards in shader-glsl.ts and the TSL twin) must be bypassed when τ is significant — a black splat still absorbs (a pure-ink occluder via gain → 0 must keep its optical depth). Under LUXAR_VOLUMETRIC, discard only when the color AND τ are both negligible. The earlier intensity discard (:409) stays: the τ it can drop is bounded by κ·opacity·1e-4 per fragment — invisible at slider κ ≤ 10 (risk #7).

5.4.1 Per-element opacity via the color ALPHA channel (Phase 2 — IMPLEMENTED)

Phase 2 does NOT add a parallel absorption_weights array (the original plan, superseded 2026-07-20). Instead the colors attribute widens from strictly (N, 3) RGB to optionally (N, 4) RGBA, and the alpha column is per-element opacity aᵢ ∈ [0, 1] — one new concept, no new parameter, and it rides inside colors so almost every gsplat op carries it for free (mask/permute/concat).

Per-mode consumption — alpha is active in EVERY blending mode, each consuming it the way it consumes node opacity (a splat’s rendered mass is A·aᵢ, so the two decouple emission from opacity):

mode

how aᵢ enters

additive / luminous / max / opaque

intensity *= aᵢ (linear contribution scale)

normal

coverage-alpha × aᵢ (true per-element alpha compositing)

volumetric

intensity *= w(aᵢ) before τ, where w(aᵢ) = −ln(1 − min(aᵢ, 1−1/512))

The volumetric mapping makes per-element alpha compose as OPTICAL DEPTH; the “peak rendered alpha = aᵢ” reproduction is exact when the remaining τ factor equals 1 — which is now the SAME condition for all three geometry types (κ = 1 with a peak-normalised ray mass), since points and lines no longer carry a world-unit size × chord scale in τ (see the 2026-08-02 status block) — and emission self-screens to ≈ c·aᵢ in that case. Dilute limit: w ≈ a as a → 0, so volumetric and additive agree there (the same κ→0 coherence carried to per-splat alpha); at large a volumetric is intentionally denser (optical-depth semantics). Mid-alpha renders therefore differ between modes — documented, not a bug.

Storage / encoding: colors shape (N, 4); alpha in [0, 1], validated (finite, bounded) and never HDR (the SDR/HDR autodetect and display-range scan look at RGB only, dataset_writers/colors.py). Codecs are channel-agnostic (rgb_uint8 element-wise; geolog_perchannel derives column count from data), so no format-version bump — old readers that hardcode 3 are the only ones affected, and Luxar’s own readers key off the array shape. absent ⇒ aᵢ = 1 (the writer stamps 1.0 into texel3.y unconditionally — pool textures are reused; a full RGB dataset allocates no wider buffer).

Gate: a uniform uHasElementAlpha (0/1, from the loaded color layout, set per-commit) gates ONLY the volumetric w-mapping — RGB data carries the identity alpha 1.0, which must NOT map to w ≈ 6.24. The linear per-mode factor needs no gate. uHasElementAlpha is a plain uniform, deliberately NOT a shader define, so toggling it never triggers a TSL graph rebuild.

Restriction: only direct-color splats get per-element opacity. Intensity/ colormap (CLUT) splats have no stored color, so alpha falls back to the node dials — correct for fitted microscopy, where τ ∝ amplitude is already the right model. (LUT-alpha ramps for CLUT mode: a coherent future extension, out of scope.)

Import / export: the classical importer stops folding opacity into amplitude — alpha := o, amplitudes := 1, and stats["interop"]. opacity_in_alpha = True marks the provenance. Additive renders stay visually identical (c·a vs the old baked c·o); normal and volumetric become correct (dark solid surfaces occlude). INRIA PLY export reads alpha verbatim into both data-driven opacity policies, so the round-trip is lossless. Mass-ranked ops (LOD-ladder scorers, culling, gsplat info) switch to the alpha-effective amplitude A·aᵢ (gsplats/utils/alpha.py::effective_amplitudes) so imported scenes keep a meaningful energy order.

LOD merge: a substitutive reduction aggregates the alpha column in w-space — the mass-weighted mean of −ln(1−aᵢ), mapped back through 1−e^(−w) (_substitutive/kmeans_lloyd.py). Optical depth composes linearly; opacity does not, so an o-space mean would over-report transmittance when a bin mixes opaque and translucent members. A uniform-alpha bin is a fixed point.

The ALPHA_CLAMP = 1 − 1/512 literal is shared between Python (gsplats/utils/alpha.py) and both viewer shaders (GLSL + TSL) so aggregation and rendering agree.

5.5 Layers-panel UI

  • κ slider cloned from the opacity-slider block (ui/layers/layer-controls.ts): LabeledSlider, layer.absorption state field, applyAbsorption on LayerApplyEngine (ui/layers/layer-apply.ts, beside applyOpacity), updateAbsorption on the LuxarMaterial interface (ui/layers/luxar-material.ts).

  • The track is logarithmic with FIXED bounds (ui/layers/absorption-range.ts): one 0.001–10 span for every layer and every geometry type. Since the 2026-08-02 convention unification, τ = κ · rayMass uses the same peak-normalised ray mass in all three families, so κ ≈ 1 is the useful anchor everywhere and κ = 10 is already far past opaque. HISTORY — do not reintroduce: the track used to be DERIVED per layer as ABSORPTION_TAU_TARGET / (thickness · chord) from max_width / max_radius, because points and lines multiplied τ by a world thickness gsplats had no counterpart for (gsplats returned no stat at all). That made κ a per-unit-length coefficient for two families and dimensionless for the third, so the stretch was a units conversion smuggled into the UI — and it could never serve a MIXED subtree, since a lift_points_to_gsplats LOD ladder composes ONE κ over both families. Removing the chord factor from the shaders removed the need for it. If a per-layer bound is ever needed again, the shader conventions have drifted apart.

  • The current κ is always representable on the track, or the readout would show a value the thumb cannot express and a touch that moves nothing would write the clamped end back. Three mechanics enforce that: κ = 0 gets a DEDICATED stop at position 0 (the geometric span starts one DOM step in, so min itself round-trips and cannot collapse to zero); max is raised to an authored κ above the nominal maximum; and min is LOWERED onto a positive authored κ beneath the nominal floor. Two clamps bound that accommodation — ABSORPTION_MAX_LIMIT and ABSORPTION_LOG_DECADES_MAX — because unbounded accommodation would compress the useful region off the track and recreate the original bug. Outside them the thumb seats at the clamped end: accepted, because past the ceiling both κ are far beyond opaque and below the floor both are ≥ 7 decades below visible absorption, so the swapped states are visually identical.

  • On a log track the input’s native value is a normalised POSITION, so the component mirrors the κ readout into aria-valuetext (assistive tech would otherwise announce the position). Linear tracks need no override.

  • Visibility: the slider is shown/enabled only when the selected layer’s effective mode is volumetric — κ is inert elsewhere and the UI should say so. Sync with the existing dropdown-change handler (layer-controls.ts:214-224).

  • Layer init reads the raw node attr (node.attrs.absorption ?? 1.0), exactly like opacity — NOT the composed value. The panel’s composeEffective substitutes each layer’s live values per ancestry node, so a composed init would multiply ancestor κ in twice. (The composed-init rule applies only to nearest-setter-wins attrs like blending_mode; multiplicative attrs must stay raw.)


6. Interactions with existing systems

  • LOD / streaming: substitutive levels pin total mass per barrier group by default, and τ ∝ mass along the ray ⇒ absorption strength survives LOD switches without popping. Enabled (post-phase-4 follow-up): volumetric is now in BLENDABLE_MODES = {additive, luminous, volumetric} (scene/lod-fade.ts), so a streaming volumetric leaf gets the additive-ladder energy compensation 1/e(k) (PR #541). Since opacity linearly scales τ (§3.1), the boost restores the partially-committed ladder’s optical depth in aggregate. Be precise about what that does not mean: e(k) is a GLOBAL energy fraction and a committed prefix is a SUBSET of splats, so the boost is per-ray exact only under proportional thinning (an idealization) — in reality rays through the committed core are over-boosted and rays through only-missing splats get nothing. That is the same structural approximation the additive/luminous path has shipped since the compensation landed, so volumetric is not held to a lower bar. The volumetric-specific twist: on individually optically-thick splats the per-splat self-screening S(Bτᵢ) saturates emission, so a large boost deepens occlusion more than it brightens; bounded by the shared ENERGY_FLOOR = 0.1 cap (≤ 10×), transient (decays as e(k) → 1), with ?noLodEnergy as the escape hatch and a volumetric-specific floor the obvious knob should a thick-splat scene ever show transient dark blobs while streaming. A deliberate single-set/shared-cap policy rather than a split predicate. Chunks arrive in energy order, not depth order: fine, the sort worker re-sorts on every commit (Phase-2 sorting contract), and I3 bounds the transient error.

  • LOD cross-fade (scene/lod-fade.ts): enabled for volumetric in the same change. An opacity fade is ghost-free (opacity scales τ — §3.1), unlike normal where depthWrite complicates fading. With weights w/1−w the pair composites to 1 − exp(−(w·τ_fine + (1−w)·τ_coarse)): endpoints exact, and in between the absorption moves monotonically between the two levels’ own absorptions (a log-space, transmittance-multiplicative interpolation — always bracketed, never a ghost outside either level). It degenerates to a constant 1 − e^(−τ) only where both levels present the same per-ray τ; the build invariant is total mass per barrier group, not per ray, and a coarse level is by construction a different spatial distribution, so absorption is NOT invariant mid-fade in general — do not build on that. The guaranteed monotone-bracketed dissolve is what anti-popping needs, and is strictly better than the hard swap it replaces. Mid-fade the two co-located sibling meshes are whole-mesh ordered by the renderOrder pass (splats of the two levels never interleave in the draw order); with near-identical bounds the containment rule usually decides, deterministically, which draws first (larger bounding sphere). That is acceptable rather than merely “benign”, and the two channels differ: combined transmittance is exactly order-independent (transmittances multiply), so occlusion of anything behind the pair is correct at every fade weight; emission is order-dependent, but only where the two levels’ local radiance differs — equal-color fragments commute exactly under over-compositing. In the optically thin regime, where each alpha is linear in its w/1−w- scaled optical depth, the ordering residual α_fine·α_coarse·(c_fine − c_coarse) is second order in the alphas (and optical depths) but first order in the local radiance difference. For individually thick fragments both alphas can saturate, removing that second-order alpha suppression while the residual stays first order in radiance difference. In either regime its magnitude is bounded by the local inter-level radiance difference |c_fine − c_coarse|. That is a bound on the ordering residual, not on the rendered hard-swap pop, which also depends on both alphas and the background and may be smaller (even zero). Do not restate this as “absorption is conserved exactly”; the accurate summary is transmittance is order-exact; emission ordering error is thin-regime second order in the alphas and always bounded by the local radiance difference. ?noLodFade is the escape hatch.

  • Tone mapping / HDR: pure additive accumulates without bound and can blow out under ACES; volumetric bounds accumulated radiance near c/κ, improving tone-mapped appearance on dense scenes. Emission remains unclamped HDR — a splat can emit more than it occludes (same deliberate asymmetry normal mode has; a property, not a bug — §9).

  • Inter-node overlap: within a node, exact back-to-front per splat; across nodes, the global renderOrder pass orders whole meshes — interleaved splats of different nodes composite approximately. Same caveat normal already carries; invisible at small κ. One case is handled exactly-enough: a node whose bounds strictly contain another node’s (a reference marker embedded in a large cloud) always draws FIRST, so the embedded node composites on top instead of being erased by the container’s whole transmittance for ~half of all camera orientations (see depth-sort-coordinator/render-order.ts::orderGroupsWithContainment).

  • >16D / WASM: the sort kernel and the ray-integral math are unaffected by dimensionality concerns (both operate on the 3 displayed dims); no new WASM kernel is needed — τ/α/S are per-fragment shader math.


7. Phased implementation plan

Phase 1 — gsplats, node-level κ (the core; independently shippable)

  1. Python: enum member, validate_absorption, Node.absorption, default stamps (§4), CLI (cli/gsplat_ops/scene_commands.py — mode help + --absorption threaded into both add_gsplats_* call sites), docs mode lists (§8). Points/lines materials get the additive-state fallback (§5.1; since removed — phases 3–4 shipped the real math).

  2. Viewer: tuple entry, isVolumetricMode, needsDepthSort, blend-state branch, composer + uniform plumbing, GLSL + TSL fragment branches, applyBlendingMode cases, sort-gate replacements, renderOrder inclusion, panel slider (§5).

  3. Tests per §8. Exit criteria: I1 pixel-compare E2E green; I2 unit test green; codegen snapshot gsplat-volumetric committed; parity suite green; per-mode blend-state E2E extended to 6 modes; full unit + targeted E2E green.

Phase 2 — per-splat absorption_weights + 3DGS import mapping (§5.4): format array + GSPLATS_ZARR_FORMAT.md, viewer attribute (present-only), LOD-merge aggregation, importer mapping, gsplat info/filter awareness. Exit criteria: imported 3DGS scene renders with per-splat occlusion; absent-array path allocates nothing (assert in a unit test).

Phase 3 — points (IMPLEMENTED 2026-07-24): extend the depth-sort infrastructure to point nodes (centers sort directly — the same kernel input shape as splat centers); chord integral through the existing super-Gaussian radial profile (rendering/materials/point/shader-glsl.ts:189-198; for a Gaussian-profile point the math is the isotropic special case of §3.1). Per the three-geometry symmetry rule: same mode name, same attr, shared needsDepthSort. Showcase + exit criterion: switch the mandelbulb demo (generate_mandelbulb_volumetric in packages/luxar/src/luxar/demos/demo_mandelbulb.py) to blending_mode="volumetric" — before this phase it had to dim its colors ×0.1 to keep the dense fractal surface from blowing out under additive; volumetric’s bounded accumulation removes that workaround and adds real depth cueing to the surface.

Phase 4 — lines (IMPLEMENTED 2026-07-24): segment-midpoint depth sort (standard approximation; artifacts only when long segments interleave — subdivision if ever needed) — the lazy midpoint provider the lines depth-sort integration already registered, now engaged via needsDepthSort(mode) with zero coordinator change since the effectiveGeometryMode downgrade helper was deleted. Both line shader backends gained the LUXAR_VOLUMETRIC output branch (shared S(τ) series, output alpha 1 − e^(−τ), color-discard bypassed while τ is significant). Lines RGBA colors land the per-endpoint alphas in line-texture texel5.zw (the slots the texture migration reserved), read through sanitizeAlpha and interpolated along t in the vertex stage; w(a) applies under volumetric gated by uHasElementAlpha, plain linear scale in every other mode.


8. Test plan

Every SSOT list that must grow for a 6th mode (inventory from the 2026-07 blending campaign):

  • src/tests/e2e/blending-expected-state.ts — the phase-1 per-geometry split is GONE: since phase 4 all three geometry types consume the single shared EXPECTED_BLEND_STATE.volumetric row (the premultiplied state — CustomBlending 5, AddEquation 100, One 201, OneMinusSrcAlpha 205, depthWrite false). The last interim fallback expectation (EXPECTED_LINE_VOLUMETRIC_STATE) was deleted with the fallback itself; the per-mode loops (blending-modes.spec.ts points loop, lines-blending-modes.spec.ts) assert the real volumetric state.

  • Codegen SHADERS lists: tsl-codegen-snapshot.spec.ts + harness registries (tests/e2e/harnesses/tsl-harness/{gsplats,points,lines}.ts) — gsplat-volumetric (phase 1), the points variants (phase 3), and line-volumetric (phase 4) all registered with committed snapshots and parity-spec coverage.

  • tests/unit/rendering/materials/gsplat/blending-mode.test.ts — state + define + projection-mode asserts for the new branch, GLSL and TSL.

  • Python typing_utils/tests/test_enums.py:22-30 — hard-coded member count 5 → 6; validation error-message test (lists all 6).

  • Docs mode lists: docs/guides/user/LUXAR_ZARR_FORMAT.md:237,489, docs/specs/GSPLATS_ZARR_FORMAT.md:367, CLI help.

Invariant and behavior tests:

  • I1 E2E pixel-compare: same deterministic fixture rendered once additive, once volumetric with absorption 0 → identical pixels (tolerance 0; same blend arithmetic). Then absorption > 0 → measured darkening behind a front splat (discriminator sampling per the blending-modes E2E pattern, ?dpr=1).

  • I2 unit test: closed-form check — composite two half-mass fragments back-to-front in TS using the shader formulas, compare to the single-splat formula to 1e-6.

  • Ordering E2E: reuse the front-to-back reversed-overlap fixture pattern (test_gsplats_normal_overlap_reversed) with volumetric mode — asserts the sort actually engages for the new mode (fail-first against a build without the needsDepthSort change).

  • New deterministic fixture(s) in tests/fixtures/generate_test_data.py (auto-generated by the vitest globalSetup manifest).

  • Numerical: S(τ) series/branch cross-check at τ ∈ {0, 1e-6, 1e-3, 1, 10}.


9. Risk register

  1. Sorting cost now applies to more scenes. Any volumetric node pays the SortWorker path (dispatch hysteresis, per-commit re-sorts). Mitigated by the Phase-2/3 sorting contract already meeting 10M+ splats; explicitly NOT mitigated by skipping the sort at κ = 0 (mode gates sorting; predicates stay simple — a user wanting free additive uses additive).

  2. Numerics at τ → 0. S(τ) is 0/0; the series branch (§3.2) is mandatory, and I1’s exactness depends on it. Covered by unit tests.

  3. HDR asymmetry. Emission is unclamped while absorption saturates at 1 — a bright splat brightens more than it occludes. Inherent to HDR scientific rendering (shared with normal); documented, not “fixed”.

  4. Taxonomy drift. volumetric is the first sum-projected sorted mode; any future code that infers “sorted ⇒ peak” or “sum ⇒ commutative” from the old alignment is wrong. needsDepthSort is the only sanctioned order-dependence predicate; usesPeakProjection the only projection one.

  5. Per-splat weights format churn (phase 2): +4 B/splat when present, importer/LOD/merge surface. Deferred by design; the absent-array fast path keeps phase 1 format-neutral.

  6. Mode-switch state machine. applyBlendingMode now manages three define/projection/blend combinations (plain, NORMAL_PREMULT, VOLUMETRIC), and every non-volumetric branch must clear LUXAR_VOLUMETRIC (including the normal branch — a volumetric→normal switch must not strand the define). The campaign’s mutation-tested branch tests must grow with it. The TSL rebuild boundary is NOT already correct: additive↔volumetric crosses a build-time output branch without crossing usesPeakProjection or isNormalMode, so the predicate gains the outputBranchChanged term (§5.4); a fail-first rebuild-boundary test pins it.

  7. Discard-threshold τ loss. The intensity early-discard drops fragments whose τ ≤ κ·opacity·1e-4 — invisible at slider range (κ ≤ 10 ⇒ α ≲ 0.1%), lossy only for extreme Python-set κ (~10⁴). Documented at the discard site; the COLOR discard, by contrast, is bypassed in volumetric (§5.4) because a black splat must still absorb.


10. Changelog

  • 2026-08-02 — Ray-mass convention unified. τ = κ × the same ray mass the geometry’s additive branch emits, for all three types; the point/line world-thickness factors and both chord constants (materials/{point,line}/math.ts) are deleted. Root cause: a point’s opacity is a peak screen alpha, so the extra length read it as a volume density in volumetric only — making a Points node and its lift_points_to_gsplats twin disagree by one path length, unfixable in the lift (two constraints, one left-hand side). Shipped with an energy-preserving 2D dilation for gsplats (Mip-Splatting √(detΣ/detΣ')), an INDEPENDENT defect that inflated sub-pixel splats in every sum mode including additive, by a factor that grew as the camera pulled back. Effect B ALSO caused a brightness step at LOD switches in a PURE-gsplat substitutive ladder — measured on a mass-conserving 4-level ladder in plain additive, where every level should paint the same total light: the finest-vs-coarsest step was 1.15× at σ_px = 1.31 and 4.35× at σ_px = 0.33, against analytic predictions of 1.17× and 3.78×; after the compensation, 0.99× and 1.13×. So this is a fix for gsplat LOD popping generally, not only for mixed points→gsplat ladders. Measured on a 4-radius Points-vs-lifted-twin parity scene: gsplat/points brightness ratio 3.75/1.45/1.07/1.02 → 1.04/1.02/1.02/1.02, and the whole κ-response curve now overlays row-for-row (κ* ratio 0.97/1.02/1.02/1.00, was 208/34.6/8.5/3.1). The per-layer κ slider track (absorptionBoundsForNode) retires with it — it was a units conversion for exactly this factor and could never serve a mixed points→gsplat LOD ladder. NOT addressed: peak-projection modes, where the lift’s sum-only calibration leaves a separate 1/(uRIF·σ) mismatch — but the three modes do not share one number, so the earlier “12–39× measured” here was imprecise: that band is the max crop-MEAN ratio at r=0.05 and r=0.02 alone. Gsplat/points PEAK ratio over the four radii: max 30.66/12.15/4.05/1.57 and normal 29.73/7.81/1.835/1.028, both BRIGHTER, with normal understating the divergence because its gsplat peak is already saturating near 1.0 (0.91/0.86 at the two asserted radii). After #1994 restored point/line alpha-over, opaque measures 4.386/0.720/0.219/0.055: the gsplat peak remains effect C, while the points peak grows with radius under depth-tested alpha-over, so the divergence changes sign and then grows toward the coarse radii.

  • 2026-07-24 (later) — Phase 4 (lines) implemented — the plan is complete: transverse chord-integral rayMass through the Gaussian-profile ribbon (LINE_CHORD_SCALE = √(π/ln 100), rendering/materials/line/math.ts), LUXAR_VOLUMETRIC output branch in both line shader backends (τ = κ·density·rayMass, shared S(τ) series, 1 − e^(−τ) alpha, color-discard bypass while τ is significant), uAbsorption/uHasElementAlpha + updateAbsorption/ updateHasElementAlpha on both line materials (clone-carried), lines RGBA colors end-to-end (Python writer channels=(3,4), loader colorComponents threading, worker de-interleave through the existing interpolate_scalars_batch scalar kernel — no WASM change, per-endpoint alphas in texel5.zw read through sanitizeAlpha and mixed along t), effectiveGeometryMode DELETED entirely (it became identity — lines volumetric sorts via needsDepthSort(mode) with the existing lazy segment-midpoint provider, zero coordinator change; the two coordinator lines pins inverted), panel κ slider shown for lines layers, EXPECTED_LINE_VOLUMETRIC_STATE deleted (lines join the shared volumetric expected-state row), line-volumetric added to the codegen SHADERS list and the parity spec.

  • 2026-07-24 — Phase 3 (points) implemented: isotropic chord-integral rayMass (POINT_CHORD_SCALE = √(π/K)), LUXAR_VOLUMETRIC output branch in both point shader backends, uAbsorption/uHasElementAlpha on both point materials, points RGBA colors end-to-end (validator channels=(3,4), accumulator/loader/projection/texel-writer stride threading, alpha in texel2.y), effectiveGeometryMode narrowed to a lines-only fallback, panel κ slider shown for points layers, mandelbulb demo switched to volumetric (×0.1 dimming removed).

  • 2026-07-19 (later) — Pre-implementation review corrections: TSL output branch is build-time ⇒ additive↔volumetric requires a rebuild (outputBranchChanged predicate, §5.4/risk #6); phase-1 points/lines additive-state fallback (§5.1); layer-state κ inits raw, not composed (§5.5); energy compensation does not fire for volumetric in phase 1 (§6); per-geometry E2E expected-state split (§8); volumetric color-discard bypass

    • intensity-discard τ-loss bound (§5.4, risk #7); CLI path erratum.

  • 2026-07-19 — Initial spec. Design decisions settled with the user: mode name volumetric; κ as node-level composable absorption attr (multiplicative, identity/default 1.0, default-stamped like opacity); opacity scales density (emission AND τ); per-splat absorption_weights spec’d but deferred to phase 2 with the 3DGS opacity mapping; phasing gsplats → per-splat → points → lines.