HDR colour best practice for Dark Cosmos
HDR colour best practice for Dark Cosmos
Section titled “HDR colour best practice for Dark Cosmos”Research, 2026-10-08. Scope: how to make neon as intense as possible now that HDR output works on
Apple TV 4K (fork efa0097) and macOS EDR. Read-only research: no repo file changed.
Engine source cited from the shipping fork checkout ~/Claude/godot-tvos/godot @ efa009712e
(4.7-dev, Metal fork). I spot-checked blit.glsl and environment_storage.cpp against upstream
godotengine/godot tag 4.7-stable, and the HDR lines are identical (blit clamp at line 169, AgX/Reinhard
white at 252/260).
Bottom line first: the current setup (Linear tonemapper, contrast 1.08 and saturation 1.14 applied after the tonemap) has no highlight roll-off at all. Every value above the headroom (≈4.93 on ATV) is hard-clipped per channel in the final blit, and the grade expands highlights before that clip. “As intense as possible” is mostly limited by how many things clip, not by a missing boost. The biggest wins:
- a hue-preserving soft shoulder (a fork shader change, no extra pass);
- one consistent “HDR multiplier” convention across draw paths (it currently means two different things);
- a glow threshold tied to headroom.
1. How Godot 4.7 maps scene-linear values to the HDR swapchain
Section titled “1. How Godot 4.7 maps scene-linear values to the HDR swapchain”Pipeline for our root viewport (Mobile renderer, use_hdr_2d=true, BG_CANVAS, glow on). File
paths are relative to the Godot source root.
-
Canvas draw (layers ≤
canvas_max_layer, default 0) into an RGBA16F target. [Certain] Withuse_hdr_2d, scene buffers areR16G16B16A16_SFLOATand the luminance multiplier is 1.0 (storage_rd/render_scene_buffers_rd.h:195,render_scene_buffers_rd.cpp:864). So the Mobile “max 2.0” limit (RGB10A2) in theEnvironmentdocs does not apply to us. That limit only applies to 3D without HDR 2D (renderer_scene_render_rd.cpp:730,limit_agx_white).-
Trap: two meanings of an “overbright” colour. [Certain]
- Path A:
modulate,draw_*and vertex colours are treated as sRGB-encoded and linearised with the fullpow(…,2.4), even above 1.0 (renderer_canvas_render_rd.cpp:2426-2428,core/math/color.h:192-197,shaders/canvas.glsl:176-178). - Path B: MultiMesh instance colours are NOT linearised. They multiply in raw
(
canvas.glsl:254-256; the repo already knows this, seerender/zone_field.gdshader:14). - The same
2.0therefore gives 4.95 linear on path A and 2.0 linear on path B.
Authored 1.25 1.5 1.6 1.7 2.0 2.5 Linear via path A 1.67 2.54 2.95 3.39 4.95 8.35 So
meta_shop_panel FLASH_HDR 2.0, or anyColor(…)*2drawn viadraw_*/modulate, already sits at the ATV ceiling.SwarmRenderer.HOT_CORE_BOOST 2.0(MultiMesh) sits at 2.0, i.e. 40% of the headroom. - Path A:
-
-
Glow gather (
shaders/effects/blur_raster.glsl:205-208; Forward+ equivalentcopy.glsl:197-200). [Certain]- It works on linear, pre-tonemap values.
- The key is the max channel, not luminance:
feedback = max(smoothstep(threshold, threshold + hdr_scale, maxc), glow_bloom), thenmin(…, luminance_cap=12). glow_hdr_scaleis the soft-knee width above the threshold, not a spread or blur-pass control. The comment inrender/environment_look.gd:21(“tighter bloom spread = fewer blur passes”) is wrong. With threshold 1.0 and scale 1.5, glow ramps in from 1.0 and reaches full at 2.5.glow_bloom 0.05lets 5% of everything glow regardless of the threshold.
-
Tonemap pass (
shaders/effects/tonemap_mobile.glsl; desktop twintonemap.glsl). The order is the same in both:- exposure;
- glow added before the tonemap (additive:
color + glow,tonemap.glsl:415-417,tonemap_mobile.glsl:753); apply_tonemapping(tonemap_mobile.glsl:769);- adjustments (BCS) after the tonemap (
tonemap_mobile.glsl:789-818,tonemap.glsl:909-941).
[Certain]
output_max_value=Window.get_output_max_linear_value()whenever the render target is HDR, else 1.0 (renderer_scene_render_rd.cpp:733-747; the viewport fills it from the context driver only while HDR output is enabled,renderer_viewport.cpp:369-376).- Linear (
tonemap.glsl:247-249): returns the colour unchanged. No clip here.white = output_max_value(storage/environment_storage.cpp:236-237), and white is only used by the Screen glow blend. - Reinhard (
tonemap.glsl:92-97):x·(1+x/(W²/M))/(1+x/M), withW = max(M, tonemap_white)(environment_storage.cpp:255-261, 286).- If
tonemap_white ≤ M, then W = M and the curve is exactly identity (x(1+x/M)/(1+x/M)): Reinhard degenerates to Linear and the blit hard-clips. - It only rolls off when
tonemap_white > M. That compresses the whole range, e.g. W=8, M=4.93: 1.0→0.90, 4.93→3.40, 8→4.93. [Certain, arithmetic]
- If
- AgX (
tonemap.glsl:198-228,environment_storage.cpp:242-252):- Headroom-adaptive.
white = max(2, tonemap_white) × M, and the allenwp shoulder asymptotes to M. - Clipped to M before the outset matrix. It can return negative components.
- By design it desaturates bright colours toward white (the “path to white”).
- Its matrices were tuned subjectively for Rec.709 SDR (comment at
tonemap.glsl:211-214).
- Headroom-adaptive.
- Filmic / ACES: “only support SDR”. White is fixed and the output is normalised to ≈1.0
(
environment_storage.cpp:238-240,tonemap.glsl:113-143). They erase HDR highlights. The official HDR doc agrees (“ignore output max linear value entirely and always produce an image in the SDR range”). [Certain] - All non-Linear tonemappers clamp negatives to 0 first (
tonemap.glsl:251-253). [Certain] - Adjustments with HDR values [Certain from the code; the visual effect is Likely]:
- Brightness multiplies linear values.
- The value is then sRGB-encoded (
linear_to_srgbworks above 1). - Contrast =
mix(0.5, c, 1.08)in encoded space. This expands highlights: linear 4.93 → encoded 1.996 → 2.116 → linear 5.65, past the clip. - Saturation =
mix(mean(c), c, 1.14)in encoded space. It pushes the max channel up and the min channel down. On bright colours that means more per-channel clipping, plus negative minima that the blit clamps to 0. - Then decode to linear (
convert_to_srgb=falsewhen HDR). - In SDR the same grade runs and the 8-bit target clips at 1.0, so the grade’s effect on highlights is a clip in both modes.
-
Layers above
canvas_max_layer(HUD at layer 5, gravity lens 2, screen punch 3) are drawn after the tonemap pass (renderer_viewport.cpp:667-676, 716-737). So the HUD gets no glow, no grade and no tonemap. Its 1.0 is reference white. [Certain] -
Final blit to the swapchain (
shaders/blit.glsl:155-172). For the extended-linear target:clamp(color, 0, output_max_value)and then× reference_multiplier. [Certain]- This is where Linear clips: hard, per channel, at M.
- It also clamps all negatives to 0 (no wide gamut, see section 3).
- On Apple,
reference_multiplier = ref / linear_scale, and the display server sets both to the same value (display_server_apple_embedded.mm:852-855,renderer_compositor_rd.cpp:205-212). So it is 1.0: linear 1.0 = SDR/UI white (the EDR model).
-
Headroom on ATV.
M = max_luminance / reference_luminance.- On tvOS the fork assumes
potentialEDRHeadroom = 1000/203 ≈ 4.93when the screen reports 1.0 (platform/tvos/display_server_tvos.mm:223, 378-394). - The real compositor mapping to PQ nits on the TV is not exposed. The “493 nits” readout is nominal (100 nits × 4.93). [Guessing that Apple maps EDR 1.0 near 203 nits PQ in HDR10 mode. Nothing public confirms it for tvOS.]
- Apple embedded refuses game-side reference/max luminance overrides
(
display_server_apple_embedded.mm:901-903, 924-926:ERR_PRINT_ONCE, a no-op). So a peak or paper-white slider must live in game code or in the fork. [Certain]
Answer to the specific questions:
- Does Linear clip at max_linear? Yes. It is not the tonemapper that clips but the blit, per channel.
- Do AgX and Reinhard adapt to the headroom? Yes, both. Filmic and ACES do not.
- When do glow and the grade run? Glow runs before the tonemap, the grade after.
- What about the 2D canvas with
BG_CANVAS? It goes through exactly this path. Only canvas layers ≤canvas_max_layerare tonemapped.
2. Best practice for neon and stylised HDR
Section titled “2. Best practice for neon and stylised HDR”-
Reference / paper white.
- BT.2408 defines HDR reference (“graphics”) white as 203 cd/m². Unreal maps 203 nits to scene 1.0 (Epic MediaReferenceWhite, W3C PNG thread quoting BT.2408). [Certain]
- Godot’s doc: reference white is “typically around 100 to 300 nits and is always represented by an output max linear value of exactly 1.0” (Godot 4.7 HDR output). [Certain]
- Apple sets reference white from the user’s brightness setting, so we cannot set it (see above).
-
What goes above 1.0. Keep the body of the image at or below reference:
- backgrounds and nebula;
- enemy bodies and the hull fill;
- UI and text (the HUD is post-tonemap at 1.0; never push UI above ~1.0, because white text at 4.9× is painful);
- large-area zones (Chalice pools, auras).
Spend headroom on small, short-lived, meaningful things:
- bullet hot cores;
- weapon impact flashes;
- nova/explosion fronts (brief peak, then decay);
- pickups at the moment of collection;
- the boss telegraph.
Rationale: highlights read as “brighter than white” only by contrast with a reference-level body. The common guidance is a reference/UI white around 150-200 nits with the rest of the range kept as highlight headroom (Special K paper-white notes). [Likely]
-
Avoid “everything at max”.
- The Godot doc warns about “the majority of the screen … presented at this maximum brightness for more than a short time” (doc). [Certain]
- TVs (OLED especially) apply ABL: full-field brightness is far below the 10%-window peak, so large bloomed areas dim the whole frame and kill the contrast you paid for. [Likely]
- In a bullet heaven with hundreds of additive halos, a late-run swarm can turn the screen into a clipped wall. Budget it: a gentle global “highlight density” governor, or a lower boost on halos than on cores. [Likely]
-
Saturation at high luminance.
- Per-channel clipping (what we do now) shifts hues toward the nearest secondary or white as one channel saturates: orange→yellow, purple→blue/magenta. [Certain, mechanism]
- Filmic tonemappers (AgX, ACES) deliberately desaturate brights toward white. That is good for photoreal and bad for neon. [Certain for AgX; see the Godot source comment and the AgX design]
- Frostbite’s HDR talk (GDC 2017, Fry) moved to luminance/hue-preserving display mapping, with controlled desaturation, instead of per-channel filmic curves (EA/Frostbite, slides). [Likely; I could not fetch the deck text]
- For neon: scale RGB uniformly so that max(R,G,B) ≤ M (a max-channel or “RGB-ratio-preserving”
roll-off). That keeps hue and saturation, so a saturated cyan stays saturated at the peak.
Accept a tiny path to white only at the very top, by adding back a little
(maxc − M)·kto all channels, so super-hot cores read “white-hot”. [Likely]
-
Bloom threshold relative to headroom. If the bloom threshold stays at 1.0 while the headroom grows to 4.93, everything from 1.0 to 4.93 blooms at full feedback (it saturates at 2.5). More headroom then means wider, brighter bloom, not crisper highlights. [Certain, from the shader maths] Godot also notes that glow strength “is directly influenced by the brightness of the scene” (doc). Tie the knee to M so that only the top band blooms. [Likely]
-
Per-display calibration.
- HGIG’s model is that the console or game reads the display’s tone-map luminances (MaxTML, MaxFFTML, MinTML), the TV disables its own tone mapping and hard-clips at panel peak, and the game tone-maps to that peak (HGIG announcement, DisplayDaily, overview). [Likely: secondary sources; hgig.org is the primary]
- tvOS gives us no MaxTML (and no API to read it), so ship an in-game peak slider (the “logo barely visible” calibration screen convention) that scales M down, plus an optional “highlight intensity” slider. Godot’s HDR article also recommends exposing HDR options in the game menu (Godot blog). [Likely]
-
SDR fallback must not regress.
- Godot’s design goal: toggling HDR “should only expand the dynamic range”, not brighten or saturate (Godot blog). [Certain]
- In our pipeline any tonemapper or grade change hits SDR too (same
Environment), unlessEnvironmentLook.applybranches on the HDR-output state, not just the HDR canvas. [Certain]
3. Gamut: P3 / Rec.2020 via negative extended-sRGB values
Section titled “3. Gamut: P3 / Rec.2020 via negative extended-sRGB values”-
Apple side: yes.
- In
kCGColorSpaceExtendedLinearSRGB, components outside [0,1] are valid. Negatives reach colours outside sRGB by extrapolating the sRGB primaries. Apple’s forum gives P3 pure red ≈ (1.358, −0.074, −0.012) in extended sRGB (Apple forum 116865). [Certain] - Values above the headroom clip (Apple: Displaying HDR content in a Metal layer). [Certain]
- In HDR10 mode the ATV output container is BT.2020/PQ, so P3-range colours can reach the TV. [Likely]
- What the tvOS compositor does with colours beyond the TV gamut is undocumented. [Guessing: it gamut-clips or compresses]
- In
-
Godot side: no, not today. [Certain]
blit.glsl:169clamps every channel to ≥ 0 (“Negative values may be interpreted as scRGB colors, so clip them to the intended Rec. 709 colors”).- Non-Linear tonemappers also
max(0)their input. - Canvas colours and RGBA16F targets can carry negatives (additive maths works), but they die at the blit.
-
Is it worthwhile? For a neon game, P3 is the biggest saturation gain left. Our palette already sits at S ≥ 0.92 (
ElementPalette.SAT_FLOOR), i.e. on the sRGB gamut edge. P3 greens and reds are visibly more saturated. [Likely] It costs a fork change plus a tuning pass:- In the fork blit, for
COLOR_SPACE_REC709_LINEARwhile HDR output is on, change the lower clamp from 0 to a bound such as −0.5, or clamp in P3 space: convert to P3, clamp [0,M], convert back. - Add a “gamut expand” step at the end of the tonemap pass: an sRGB→P3 reinterpretation (treat sRGB-coded saturated colours as P3 primaries, i.e. multiply by the P3→sRGB matrix), weighted by saturation so near-neutral colours are untouched.
SDR is unaffected (the 8-bit sRGB target clips). Risks:
- hue skew (P3 “stretch” shifts some hues, e.g. orange toward red);
- out-of-range negative values in glow;
- unknown compositor handling.
[Guessing on the visual payoff; Certain that it needs an engine change]
- In the fork blit, for
4. Dolby Vision
Section titled “4. Dolby Vision”- No app API for DV metadata on a real-time Metal layer on tvOS: verified, by headers.
CAMetalLayer.EDRMetadata/CAEDRMetadata(HDR10/HLG static metadata) areAPI_UNAVAILABLE(tvos)in the tvOS 27 SDK (repodocs/research/2026-10-08-tvos-edr-hdr-output.md, table).- No public DV-generation API exists for layers on any Apple platform. DV playback is AVFoundation/VideoToolbox only, for DV-encoded video.
- [Certain for EDRMetadata; Likely for “no DV API at all”: absence of evidence after search]
- DV-for-games is a Dolby technology/licensing programme.
- Xbox Series X|S shipped DV gaming in 2021. Xbox offers it through the platform or Dolby’s engine integration (Windows Central, Trusted Reviews).
- Dolby offers a Real-Time Rendering SDK / “SIDK” for custom engines and a free Unreal plug-in, via Dolby developer relations (Dolby game imaging, Dolby PC).
- Exact licence terms are not public. [Likely]
- Most Xbox “DV” titles are HDR10 wrapped in a DV container by the system, without per-title metadata (Generación Xbox). [Likely, secondary source]
- No Apple-platform equivalent exists for third-party games. [Likely]
- The app outputs HDR (EDR) and tvOS in “4K Dolby Vision” mode maps it: Likely, not verified.
- The whole tvOS UI renders in the selected HDR format (early ATV 4K reviews), so composited EDR app content is carried in whatever mode the box is in (Apple support 102339, Engadget hands-on).
- Precision: the app does not output “HDR10/PQ” itself. It hands extended-linear sRGB to the compositor, which encodes to whatever the link is (PQ for HDR10; DV when the box is in DV mode, in which case the box generates the DV signal, not us).
- Our fork’s
preferredDisplayCriteriaasks for an HEVC BT.2020/PQ format, so with Match Dynamic Range on, the TV goes to HDR10 rather than DV. [Likely] - Net effect: nothing to gain by chasing DV. [Likely]
5. Prioritised levers for this codebase
Section titled “5. Prioritised levers for this codebase”Ordered by payoff/risk. “M” = Window.get_output_max_linear_value(), re-read every frame (Godot’s doc:
it changes with brightness, mode and screen). Every number below should be a Remote Console tunable per
the CLAUDE.md rule, and HDR-only values should be gated on HdrOutput enabled so SDR stays
byte-identical.
- Stop the grade from feeding the clip.
- Change: under HDR output, set
CONTRASTto 1.0 and move the “punch” into the content, or apply contrast only below reference. Today contrast 1.08 lifts 4.93→5.65 and saturation 1.14 pushes channels out of range, so every bright neon clips harder and hue-skews. - Expected effect: cleaner, less “posterised” highlights, with hues holding in cores and explosions.
- Risk: a slightly flatter look at mid-tones under HDR. SDR is unchanged if branched on HDR output.
- Cost: a GDScript-only change (
EnvironmentLook.applygets anhdr_outarg plus separateHDR_OUT_CONTRAST/HDR_OUT_SATURATIONtunables). - [Certain on mechanism, Likely on visual]
- Change: under HDR output, set
- Hue-preserving soft shoulder in the fork (no extra pass).
-
Change: add a mode to
tonemap_mobile.glslandtonemap.glsl, or redefine Linear when HDR output is on:m = max3(c)if m > k·M: c *= shoulder(m)/mshoulderis Reinhard-like and maps [k·M, ∞) → [k·M, M), with k ≈ 0.75. Optionally add+ desat·max(m−M,0)toward white. -
It must run after BCS, or BCS must be changed to work below the knee only. Simplest: apply it as the last op before
frag_colorwhen!convert_to_srgb. -
Expected effect: overlapping additive halos stop flattening into clipped slabs, saturated neon stays saturated right up to peak, and white-hot reads only where intended.
-
Risk: fork maintenance (a shader edit; the
.gen.hregenerates). The headless gate does not compile shaders (repo CLAUDE.md), so it needs a device check. No SDR change if gated onoutput_max_value > 1. -
Alternative without a fork change: a full-screen
CanvasLayershader at layer 1 reading the screen texture. But the ATV is fill-rate bound, so that costs a full-screen RGBA16F pass. Prefer the fork. -
[Likely]
-
- One HDR-boost convention across draw paths.
-
Problem: path A (
modulate/draw_*) linearises boosts with pow 2.4, so 1.5→2.54 and 2.0→4.95. Path B (MultiMesh:SwarmRenderer, likelyArchetypeRenderer) uses the boost raw. -
Change: define boosts in linear units. Convert path-A literals with
linear_to_srgb()before drawing, or compensate in shaders aszone_fielddoes. Then express each as a fraction of headroom:boost = 1 + (M − 1)·fwithfa tunable per source:Source f cores 0.7 impact flash 1.0 halos 0.2 zones 0 UI 0 -
Expected effect: predictable intensity hierarchy, and the same look ratio on Mac (M≈1.2-2.7) and ATV (4.93).
-
Risk: a broad visual change touching many renderers. SDR is unchanged when M=1, because
boost=1+0·…. But SDR currently relies on >1 values to trigger bloom, so keep the SDR bloom-trigger boost as a separate floor:max(sdr_boost, hdr_boost). -
[Certain on the mismatch, Likely on payoff]
-
- Glow knee tied to headroom.
- Change: under HDR output, use
GLOW_HDR_THRESHOLD = 1 + (M−1)·t(t≈0.3-0.5) andGLOW_HDR_SCALE ≈ (M−1)·0.5. DropGLOW_BLOOM0.05→0 (it makes everything glow 5%). ConsiderGLOW_INTENSITY↓ under HDR, because bloom now adds visible light above white instead of clipping. - Expected effect: crisper bullets with bloom concentrated on the hottest events, less veiling glare, and less ABL dimming.
- Risk: the HDR look loses some “haze” Chris may like. Tune live on the console.
- Also fix the misleading
GLOW_HDR_SCALEcomment (it is a soft-knee width, not spread or passes). - [Certain on mechanism]
- Change: under HDR output, use
- In-game peak / “HDR brightness” setting.
- Change: game-side
effective_M = min(M, user_peak_ratio). Feed it into levers 2-4, and into the shoulder target if the fork exposes it as a uniform. A simple calibration screen: a logo at linear X on a background at M, adjusted until barely visible. - Expected effect: correct on dim or bright TVs, and protects players whose TV clips below 4.93 (the 4.93 value is assumed, not measured, on tvOS).
- Risk: UI work, plus a privacy page if the value goes to telemetry.
- [Likely]
- Change: game-side
- Saturation in linear space instead of the encoded-space BCS.
- Change: replace the
SATURATION 1.14BCS (encoded-space, equal weights, which shifts blue brightness) with a Rec.709-luminance-weighted saturation in linear space. Do it in the fork’s tonemap, or pre-bake it into the colour sources (ElementPalette.vividalready floors S at 0.92, so the BCS is largely redundant for hued sources and mostly over-saturates mixes and halos). - Expected effect: same vividness with fewer clipped channels and no blue/yellow brightness skew.
- Risk: a subtle palette change in SDR if applied there too. Gate it.
- [Likely]
- Change: replace the
- Tonemapper choice: do not switch to AgX/ACES/Filmic; Reinhard only with a care.
- ACES and Filmic cap output at ≈1.0, which kills HDR.
- AgX adapts to headroom but desaturates toward white and changes the SDR look heavily (white ≥ 2×M re-curves everything).
- Reinhard with
tonemap_white ≤ Mis identical to Linear. With white > M it dims mid-tones too (1.0→0.90 at W=8), and in SDR (M=1) it darkens the whole game. - Recommendation: keep
TONE_MAPPER_LINEARand own the shoulder (lever 2). If no fork change is possible, Reinhard with white ≈ 1.5-2×M, set only while HDR output is on, is the stock fallback for avoiding hard clips. - [Certain on the maths]
- Wide gamut (P3) expansion (section 3).
- Change: a fork blit clamp change plus a gamut-expand step.
- Expected effect: the largest remaining saturation gain for reds and greens.
- Risk: highest of the list (hue skew, unknown compositor behaviour, a new fork divergence). Do it after 1-5, behind an A/B row.
- [Guessing on payoff]
- Keep the HUD at ≤1.0. The HUD is post-tonemap at layer 5. Audit UI
Color(>1)uses (e.g.FLASH_HDR 2.0= 4.95 linear = full ATV peak) and cap UI flashes at ~1.5-2.0 linear under HDR output. Risk: none for SDR. [Certain on mechanism]
Verification note. None of this can be judged headless: the gate never compiles shaders, and HDR
needs the device. Use the console live (/caps, /status hdr.max_linear) and an A/B row per lever,
per the repo’s “toggle competing techniques” rule.