Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Design: main target configuration

Provisional answer to #869: configure one renderer-owned main colour attachment and mandatory depth attachment, before allocation, through configure_main_render_target with ClientHost. The initial supported change proposed here is adding stencil through a typed depth_stencil choice. This requires renderer work; it is not available today. The colour format is never offered. This is a review proposal, not an adopted claim of full NeoForge parity.

Evidence and limits

Vanilla 26.3 was inspected only through tools/rea_vanilla.py from /workspace/horizon: class MainTarget (fully qualified com.mojang.blaze3d.pipeline.MainTarget), and methods allocateAttachments, allocateColorAttachment, allocateDepthAttachment, plus callers of createFrameBuffer. Allocation asserts the render thread, tries dimensions with fallback, and requires both colour and depth before creating their views. Colour allocation uses the target's colour format, one mip and one layer; allocation failure returns null for fallback. These are method owners, not invented Java file:line references. No Mojang files are included here.

NeoForge 26.3's issue-named counterpart is ConfigureMainRenderTargetEvent. Its exact fields, defaults, mutability, bus and recreation timing remain unverified: the public 26.3 javadoc endpoint was inaccessible on 2026-11-09. No NeoForge source or patches were consulted. Do not adopt this narrower field set as its full contract: public documentation of each field and timing is a prerequisite to approving implementation.

Verified Horizon owners at this revision (all paths relative to the repository):

  • crates/horizon-client-render/src/gfx.rs:2911: renderer construction; :3082: frame configuration selection before pipeline construction.
  • gfx.rs:5515: offscreen target helper, single sample, one mip/layer, renderer-selected format and usage.
  • gfx.rs:5582: depth helper, Depth32Float, single sample, render, sample and copy-source usage; depth consumers must survive any proposed change.
  • gfx.rs:5640: resize validates against device maximum, suspends invalid sizes, then configures surface or recreates offscreen target and recreates depth.
  • gfx.rs:5547: surface restoration; :2651: device-loss callback; :2354: frame configuration policy. Surface configuration alone is not proof that all dependent targets and pipelines have been rebuilt.

Proposed fields and refusals

FieldSupported valuesOwnership
depth_stencilDepth, DepthStencilWritable; default Depth; stencil default clear 0
extentCurrent drawable width and heightRead-only renderer decision
colour attachmentsExactly one main colourRenderer-owned
samples, mips, layers1, 1, 1Renderer-owned
depth presenceAlways trueRenderer-owned
colour format, primaries, transfer, alphaNo writable fieldRenderer-owned
depth format and usage bitsNo raw format or flagsRenderer chooses a supported compatible format

Refuse extra colour attachments, MSAA, depth removal, custom dimensions, custom usages, raw GPU objects, clear-colour overrides and colour/alpha policy requests as UnsupportedMainTargetCapability at registration, identifying the mod and requested capability. A required unsupported capability prevents that registration; never silently ignore it. This refusal owns only this feature's capability identifiers. Unknown identifiers elsewhere retain existing behaviour. Stencil is advertised only when the renderer can support it throughout all passes, depth sampling and copies; otherwise its registration is refused too. No arbitrary fixed dimension ceiling: preserve renderer/device capacity and vanilla's requested-size/fallback behaviour rather than imposing a mod-only cap.

The API owns MainTargetConfigWire, MainTargetDepthStencilWire and MainTargetCapabilityWire with explicit discriminants and checked decoding; no wgpu types or Rust memory layouts cross the wasm boundary. Native and wasm use the same configuration, capability negotiation, dependency-ordered listener sequence and validation. Unknown discriminants, oversized payloads and malformed values are refused before callbacks or allocation. There are no floating point mod fields; NaN/infinity cannot enter through a numeric escape hatch.

One recreation boundary

Client-lib decides a candidate generation with extent, device capabilities, colour policy and reload generation. The game dispatches with ClientHost, then applies the validated result in the same transaction, rechecking those preconditions without recomputing the decision. Each listener sees the preceding validated result; the last valid write wins. Invalid output refuses the whole candidate. Catch a callback unwind or wasm trap at the runtime boundary, discard its transaction and refuse the candidate with mod attribution. A native process abort cannot be contained. Do not resume allocation using partially edited state.

Validate the whole attachment/pipeline combination before GPU allocation, then build a replacement generation and atomically publish it with matching views, bind groups, generated colour boundaries and pipelines. Allocation failure drops the candidate and retains the old usable generation; first creation fails with a diagnostic. Device loss may make retention impossible: suspend rendering.

Use this boundary for initial window and offscreen creation, valid resize, restoration that replaces attachments, SDR/HDR changes, float-frame selection, shader-pack/resource reload affecting target consumers, and device recovery. Pure surface reacquisition or present-mode changes reuse the current decision if no attachment dependency changes. Zero-sized/minimized or over-device-limit windows suspend without invoking listeners or allocating; restoration makes one fresh decision for the valid size. Reload rebuilds resources and generated entry/exit conversions together. Mods receive no persistent GPU references. The colour rule remains linear light, white 1.0, values above 1 allowed, with renderer-owned frame format and alpha.

No-listener dispatch is an O(1) subscriber-presence check and uses existing renderer defaults without allocating a Wire message or scanning mods. It adds no per-frame callback, allocation or capability scan. Cache validated capabilities per device generation; recreation alone runs subscribed callbacks.

Acceptance matrix (implementation gates)

CaseConcrete expected result
No listener, 1280 × 7201 colour, depth present, samples/mips/layers 1/1/1; existing formats unchanged
Supported stencil requestDepthStencil, clear stencil 0, depth sampling/copy consumers valid
Resize to 1920 × 1080One new decision; stencil retained by listener; all views use 1920 × 1080
Resize to 0 × 0Zero callbacks, zero allocations; rendering suspended
Maximum supported extentAccepted if renderer can allocate; no extra mod ceiling
Extra colour or samples 4 requestUnsupportedMainTargetCapability at registration, zero callbacks
Unsupported stencil deviceSame named registration refusal, no partial allocation
Invalid Wire tag / callback panic / wasm trapCandidate refused; no publication or partial Host writes
SDR → HDR → float changeNew generation each effective policy change; shader white stays 1.0, bright input 2.0 stays linear 2.0 at shader boundary
Resource reload / device recoveryFresh capability validation as needed, no stale views or pipelines
Allocation failure during resizeCandidate dropped; old generation retained if usable
Native versus wasmIdentical accepted settings, order, refusals and resulting descriptor values

Proposed stages

File: none for implementation in this change. Stage 1: obtain public NeoForge 26.3 field/timing documentation and approve the supported/refused table. Stage 2: implement the API-owned Wire schema and capability registration, then the shared decide/fire/apply boundary across every route above. Stage 3: prove the matrix with descriptor tests, failure injection and native/wasm fixtures; demonstrate stencil changing an observable render and surviving resize in game. This docs-only change provides no such implementation proof.

Open questions

  • Does NeoForge expose more mandatory fields or different timing? Recommendation: require public 26.3 documentation before adopting the contract; reconcile all fields explicitly rather than claiming parity from the issue summary.
  • Can stencil coexist with every current depth-copy consumer? Recommendation: gate capability advertisement on end-to-end validation; do not advertise it merely because the device supports a stencil format.
  • Should resource reload always invoke the hook? Recommendation: invoke once when rebuilding attachment consumers; reuse the generation for unrelated reloads.
  • How should conflicting listeners be diagnosed? Recommendation: dependency order, last validated write wins, with mod-attributed diagnostics for rejected candidates.