Skip to content

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.

  1. Canvas draw (layers ≤ canvas_max_layer, default 0) into an RGBA16F target. [Certain] With use_hdr_2d, scene buffers are R16G16B16A16_SFLOAT and 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 the Environment docs 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 full pow(…,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, see render/zone_field.gdshader:14).
      • The same 2.0 therefore 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 any Color(…)*2 drawn via draw_*/modulate, already sits at the ATV ceiling. SwarmRenderer.HOT_CORE_BOOST 2.0 (MultiMesh) sits at 2.0, i.e. 40% of the headroom.

  2. Glow gather (shaders/effects/blur_raster.glsl:205-208; Forward+ equivalent copy.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), then min(…, luminance_cap=12).
    • glow_hdr_scale is the soft-knee width above the threshold, not a spread or blur-pass control. The comment in render/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.05 lets 5% of everything glow regardless of the threshold.
  3. Tonemap pass (shaders/effects/tonemap_mobile.glsl; desktop twin tonemap.glsl). The order is the same in both:

    1. exposure;
    2. glow added before the tonemap (additive: color + glow, tonemap.glsl:415-417, tonemap_mobile.glsl:753);
    3. apply_tonemapping (tonemap_mobile.glsl:769);
    4. 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), with W = 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]
    • 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).
    • 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_srgb works 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=false when 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.
  4. 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]

  5. 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).
  6. Headroom on ATV.

    • M = max_luminance / reference_luminance.
    • On tvOS the fork assumes potentialEDRHeadroom = 1000/203 ≈ 4.93 when 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_layer are 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)·k to 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), unless EnvironmentLook.apply branches 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]
  • Godot side: no, not today. [Certain]

    • blit.glsl:169 clamps 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:

    1. In the fork blit, for COLOR_SPACE_REC709_LINEAR while 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.
    2. 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]

  • No app API for DV metadata on a real-time Metal layer on tvOS: verified, by headers.
    • CAMetalLayer.EDRMetadata / CAEDRMetadata (HDR10/HLG static metadata) are API_UNAVAILABLE(tvos) in the tvOS 27 SDK (repo docs/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 preferredDisplayCriteria asks 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]

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.

  1. Stop the grade from feeding the clip.
    • Change: under HDR output, set CONTRAST to 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.apply gets an hdr_out arg plus separate HDR_OUT_CONTRAST/HDR_OUT_SATURATION tunables).
    • [Certain on mechanism, Likely on visual]
  2. Hue-preserving soft shoulder in the fork (no extra pass).
    • Change: add a mode to tonemap_mobile.glsl and tonemap.glsl, or redefine Linear when HDR output is on:

      m = max3(c)
      if m > k·M: c *= shoulder(m)/m

      shoulder is 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_color when !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.h regenerates). The headless gate does not compile shaders (repo CLAUDE.md), so it needs a device check. No SDR change if gated on output_max_value > 1.

    • Alternative without a fork change: a full-screen CanvasLayer shader 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]

  3. 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, likely ArchetypeRenderer) uses the boost raw.

    • Change: define boosts in linear units. Convert path-A literals with linear_to_srgb() before drawing, or compensate in shaders as zone_field does. Then express each as a fraction of headroom: boost = 1 + (M − 1)·f with f a 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]

  4. Glow knee tied to headroom.
    • Change: under HDR output, use GLOW_HDR_THRESHOLD = 1 + (M−1)·t (t≈0.3-0.5) and GLOW_HDR_SCALE ≈ (M−1)·0.5. Drop GLOW_BLOOM 0.05→0 (it makes everything glow 5%). Consider GLOW_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_SCALE comment (it is a soft-knee width, not spread or passes).
    • [Certain on mechanism]
  5. 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]
  6. Saturation in linear space instead of the encoded-space BCS.
    • Change: replace the SATURATION 1.14 BCS (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.vivid already 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]
  7. 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 ≤ M is 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_LINEAR and 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]
  8. 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]
  9. 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.