Intensity, Offset, and Gamma — Design Document
Status: Implemented
Problem
The current Luxar viewer has a global “Intensity” slider (HDR multiplier) that is propagated into every material shader as a per-fragment uniform. This is architecturally wrong:
A global control is implemented per-material — the MaterialManager loops over all registered materials to set the same value, when a single post-processing pass would suffice.
It couples brightness with bloom — since bloom uses a luminance threshold, changing the global multiplier changes which pixels bloom, rather than just making the image brighter.
No per-node intensity — despite having a per-node
gamma, there is no per-node linear multiplier for independent channel brightness control.No background subtraction — microscopy volumes often have a fluorescence background floor that should be subtractable per-node.
Proposed Architecture
Global Controls (vendored into tone mapping, zero extra cost)
Move global brightness/gamma out of shaders into the tone mapping shader itself.
The vendored LuxarMegaShaderMaterial applies EOG before tone mapping in a single pass:
HDR buffer → [Bloom] → [EOG + Tone Mapping (single pass)] → [Vignette] → [AA] → display
Three global controls applied to the composited HDR buffer:
Parameter |
Python field |
TS field |
Default |
Range |
Formula |
Purpose |
|---|---|---|---|---|---|---|
Exposure |
|
|
0.0 |
-10.0 – +10.0 |
|
Log2 stops (photography standard) |
Offset |
|
|
0.0 |
-1.0 – +1.0 |
|
Lift/lower the entire composited image |
Gamma |
|
|
1.0 |
0.1 – 10.0 |
|
Reshape midtones globally |
Naming convention: Global offset and gamma use global_ prefix in Python/zarr to
distinguish from per-node offset and gamma. In the UI, they are labeled simply
“Offset” and “Gamma” since section headers (“Global” vs per-node) disambiguate.
Exposure uses log2 (photography stops): 0 = neutral, +1 = 2x brighter, -1 = half.
This is the standard in photography and VFX tools (Nuke, DaVinci, Lightroom).
Replaces the current “Intensity” slider (which uses log10 scale with hdrMultiplier).
Per-Node Controls (in shaders)
Each data node (Points, Lines, GSplats, Mesh) gets three rendering parameters:
Parameter |
Python field |
TS metadata field |
Default |
Range |
Purpose |
|---|---|---|---|---|---|
Intensity |
|
|
1.0 |
0.0 – 100.0 |
Linear color multiplier (gain) |
Offset |
|
|
0.0 |
-10.0 – 10.0 |
Additive brightness shift (black level) |
Gamma |
|
|
1.0 |
0.1 – 10.0 |
Nonlinear tonal curve |
Note: Per-node intensity is NOT the same as opacity. Opacity controls alpha/blending
contribution (how transparent the node is). Intensity controls color brightness (gain).
With additive blending, opacity scales the alpha channel while intensity scales the color.
The shader applies the GOG (Gain-Offset-Gamma) model in all four material types (Points, Lines, GSplats, Mesh):
// Per-node color adjustment (applied before blending, all node types)
vec3 adjusted = color * intensity + offset;
adjusted = max(adjusted, vec3(0.0)); // Clip negatives (no negative light)
adjusted = pow(adjusted, vec3(1.0 / gamma)); // Nonlinear curve
Early discard after offset saves GPU work on Points, Lines and GSplats. Mesh omits it: an opaque surface must still write depth, so a fragment that ends up black has to be drawn rather than thrown away.
// Points / Lines / GSplats: discard zero-contribution fragments after offset
if (max(adjusted.r, max(adjusted.g, adjusted.b)) < 1e-4) discard;
Use Case: Background Subtraction
Microscopy volumes often have a fluorescence background floor. A negative offset
subtracts this floor per-node, so only signal above background contributes to rendering.
This is standard microscopy practice (black level / pedestal subtraction).
Example: Two channels in a multichannel microscopy dataset (works for any node type):
# GSplats — subtract autofluorescence floor per channel
scene.add_gsplats("DAPI", ..., intensity=1.0, offset=-0.05, gamma=1.0)
scene.add_gsplats("GFP", ..., intensity=2.0, offset=-0.02, gamma=0.8)
# Points — same model for localization microscopy
scene.add_points("STORM_ch1", ..., intensity=1.5, offset=-0.01, gamma=1.0)
scene.add_points("STORM_ch2", ..., intensity=1.0, offset=-0.03, gamma=0.9)
# Lines — e.g. neuron tracings with background subtraction
scene.add_lines("axons", ..., intensity=1.2, offset=-0.02, gamma=1.0)
Per-Node Interactive UI (Future)
The viewer will eventually support interactive per-node adjustment of intensity, offset, and gamma through the rendering controls panel. This is not in scope for the initial implementation — it requires a node selection mechanism and per-node UI controls.
Configuration Propagation
Global controls: Python → Zarr → Viewer → Post-Processing
Python: ViewerConfig(exposure=1.0, global_offset=0.0, global_gamma=1.0)
↓
Zarr: root/.zattrs → {"viewer_config": {"exposure": 1.0, "global_offset": 0.0, "global_gamma": 1.0}}
↓
TS scene-loader: root.userData.viewerConfig
↓
TS viewer-config-utils: RENDERING_SETTINGS_MAP maps snake_case → camelCase
- exposure → exposure
- global_offset → globalOffset
- global_gamma → globalGamma
↓
TS RenderingSettings: {exposure: 1.0, globalOffset: 0.0, globalGamma: 1.0}
↓
Priority chain: localStorage (user) > zarr viewer_config (author) > app defaults
↓
UI sliders (in "☀️ HDR" folder):
- Exposure: linear slider -10 to +10 stops
- Offset: linear slider -1.0 to +1.0
- Gamma: linear slider 0.1 to 10.0
↓
postProcessingManager.megaShader.uniforms
- uExposure, uOffset, uGamma
↓
Single vendored shader pass (EOG + tone mapping)
Per-node controls: Python → Zarr → Viewer → Shaders
Python: scene.add_gsplats("GFP", ..., intensity=2.0, offset=-0.02, gamma=0.8)
↓
Zarr: GFP/.zattrs → {"intensity": 2.0, "offset": -0.02, "gamma": 0.8, ...}
↓
TS scene-loader: reads attrs.intensity, attrs.offset, attrs.gamma
↓
materialManager.get*Material({intensity: 2.0, offset: -0.02, gamma: 0.8, ...})
↓
Material constructor: creates uniforms uIntensity, uOffset, uInvGamma
↓
Fragment shader: GOG model applied per-fragment before blending
Python ViewerConfig example
from luxar import LuxarZarrCompiler, Dimensions, ViewerConfig
vc = ViewerConfig(
# Global display controls (applied in post-processing)
exposure=0.5, # Half a stop brighter
global_offset=0.0, # No global offset
global_gamma=1.0, # Linear midtones
tone_mapping="ACES", # Filmic tone mapping
# Other existing settings
bloom_enabled=True,
bloom_strength=0.3,
)
with LuxarZarrCompiler("output.luxar.zarr") as compiler:
scene = compiler.create_scene(dimensions=dims, viewer_config=vc)
# Per-node controls (applied in shaders)
scene.add_gsplats("DAPI", centers, cholesky, amplitudes,
colors=colors_dapi,
intensity=1.0, offset=-0.05, gamma=1.0)
scene.add_gsplats("GFP", centers, cholesky, amplitudes,
colors=colors_gfp,
intensity=2.0, offset=-0.02, gamma=0.8)
UI Layout
The rendering controls panel would be restructured:
☀️ HDR
├── Exposure [-10 ────●──── +10] stops
├── Offset [-1.0 ────●──── +1.0]
├── Gamma [0.1 ─────●──── 10.0]
└── Tone Mapping [ACES ▾]
Future per-node controls (when node selection UI exists):
🎯 Node: GFP
├── Intensity [0.0 ─────●──── 100.0]
├── Offset [-10 ─────●──── +10]
├── Gamma [0.1 ─────●──── 10.0]
└── Opacity [0.0 ─────●──── 1.0]
Backward Compatibility
None required. All zarr data is regenerated from source. Old hdr_multiplier fields
in ViewerConfig, zarr attrs, RenderingSettings, and localStorage are simply removed
and replaced with the new fields. No migration code, no deprecated properties.
Implementation: Mega-shader Tone Mapping Stage
Why fuse EOG into the mega-shader
The global EOG controls are merged into the post-processing mega-shader, not implemented as a separate post-processing pass.
Performance rationale:
A separate full-screen pass costs one extra framebuffer read + write of every pixel
Memory bandwidth is the primary GPU bottleneck for post-processing
The mega-shader already reads every pixel — adding
exp2,add,pow(3 ALU ops) before tone mapping is effectively freeThis avoids an extra render target allocation and preserves the fused pipeline
Approach: MegaShaderMaterial exposes uExposure, uGlobalOffset, and
uGlobalGamma uniforms. mega/shader.glsl.ts applies EOG immediately before
calling Three’s tone-mapping shader chunks:
// Single fused pass: scene/bloom/noise → EOG → tone mapping → vignette
vec3 color = sampleHdrPlusBloom(vUv);
color *= exp2(uExposure);
color = max(color + vec3(uGlobalOffset), vec3(0.0));
color = pow(color, vec3(1.0 / uGlobalGamma));
color = ACESFilmicToneMapping(color); // or Reinhard, AgX, Neutral, etc.
Implementation strategy:
Keep EOG uniforms on
MegaShaderMaterialInclude Three’s
<tonemapping_pars_fragment>once in the custom shaderSet
toneMapped: falseon the material so Three does not inject a duplicate chunkUse internal tone-mapping define IDs for Linear/Reinhard/Cineon/ACES/AgX/Neutral
Preserve old
NoToneMappingbehavior by routing it to Linear/clamped output
Current State
All features described in this document are fully implemented:
Per-node GOG model (
intensity,offset,gamma) in all four material shadersGlobal EOG (
exposure,global_offset,global_gamma) in the custom mega-shaderhdrMultiplierremoved from all shaders, MaterialManager, and configPython
ViewerConfigupdated withexposure,global_offset,global_gammaFull config propagation: Python → zarr → TypeScript → UI → post-processing
Implementation Locations
Python
packages/luxar/src/luxar/core/node/node.py—intensityandoffsetpropertiespackages/luxar/src/luxar/core/viewer_config.py—exposure,global_offset,global_gammafields (replacedhdr_multiplier)packages/luxar/src/luxar/io/_compiler/node_common.py—apply_default_render_attrswrites the defaults for the Points/Lines/Mesh writers; GSplats viagsplat_assembly.pypackages/luxar/src/luxar/validation/types.py— validators for intensity, offsetpackages/luxar/src/luxar/typing_utils/constants.py— range constants
TypeScript (shaders)
Dual-stack: each geometry has a parallel GLSL (WebGL2 path) and TSL (WebGPU path) implementation; GOG uniforms live in both.
packages/luxar-viewer/src/rendering/materials/point/material-glsl.ts— GOG uniforms (WebGL2)packages/luxar-viewer/src/rendering/materials/point/material-tsl.ts— GOG uniforms (WebGPU)packages/luxar-viewer/src/rendering/materials/line/material-glsl.ts— GOG uniforms (WebGL2)packages/luxar-viewer/src/rendering/materials/line/material-tsl.ts— GOG uniforms (WebGPU)packages/luxar-viewer/src/rendering/materials/gsplat/material-glsl.ts— GOG uniforms (WebGL2)packages/luxar-viewer/src/rendering/materials/gsplat/material-tsl.ts— GOG uniforms (WebGPU)packages/luxar-viewer/src/rendering/materials/mesh/material-glsl.ts— GOG uniforms (WebGL2)packages/luxar-viewer/src/rendering/materials/mesh/material-tsl.ts— GOG uniforms (WebGPU)
TypeScript (post-processing + scene management)
packages/luxar-viewer/src/rendering/post-processing/mega/material.ts— EOG uniforms and tone-mapping mode#define(WebGL2 path)packages/luxar-viewer/src/rendering/post-processing/mega/material-tsl.ts— EOG uniforms and tone-mapping mode uniform (WebGPU path)packages/luxar-viewer/src/rendering/post-processing/mega/shader.glsl.ts— fused EOG + tone-mapping shader stage (WebGL2)packages/luxar-viewer/src/rendering/post-processing/mega/shader.tsl.ts— fused EOG + tone-mapping shader stage (WebGPU)packages/luxar-viewer/src/rendering/post-processing/post-processing-manager.ts— exposure/offset/gamma update methodspackages/luxar-viewer/src/rendering/material-manager.ts— per-node GOG uniforms in material creationpackages/luxar-viewer/src/scene/scene-manager.ts—updateExposure()/updateGlobalOffset()/updateGlobalGamma()routingpackages/luxar-viewer/src/data/scene-loader.ts— per-nodeintensity/offsetfrom zarr attrs
TypeScript (config + propagation)
packages/luxar-viewer/src/config/sections/rendering-controls/types.ts—exposure/globalOffset/globalGammain RenderingSettingspackages/luxar-viewer/src/config/sections/rendering-controls/data.ts— defaultspackages/luxar-viewer/src/config/zarr-bridge/viewer-config-utils.ts—RENDERING_SETTINGS_MAPsnake_case → camelCasepackages/luxar-viewer/src/types/zarr.ts—ZarrViewerConfigfields
TypeScript (UI)
packages/luxar-viewer/src/ui/rendering-controls/setup/hdr-setup.ts— Exposure/Offset/Gamma sliderspackages/luxar-viewer/src/ui/rendering-controls/controls-utils.ts—validateRenderingSettings()ranges
TypeScript (node types)
packages/luxar-viewer/src/types/points.ts—intensity?andoffset?inPointsMetadatapackages/luxar-viewer/src/types/lines.ts—intensity?andoffset?inLinesMetadatapackages/luxar-viewer/src/types/gsplats.ts—intensity?andoffset?inGSplatsMetadata