buildMaterial — central branching helper for the dual-stack
renderer (THREE.WebGLRenderer + WebGPURenderer).
A caller delegates inner construction here — today that is the
post-processing passes only (FxaaPass, BloomChain), the geometry
materials having their own wrappers. The helper branches on
RendererCapabilities.apiSurface:
WebGL2 path returns a configured THREE.ShaderMaterial from
source.webgl.{vertex,fragment}.
WebGPU path calls source.webgpu(uniforms) to get a
NodeMaterial.
Both webgl and webgpu fields on ShaderSource are optional in
the type so future single-backend shaders can opt out cleanly, but
the helper throws when the active backend's source is absent.
Under WebGPU specifically we do NOT silently fall back to
ShaderMaterial — WebGPURenderer cannot dispatch ShaderMaterial
even when running on its internal WebGL2 backend (see
BROWSER_SUPPORT_POLICY.md), so the silent fallback would render
blank quads. Throwing surfaces the gap at construction time
instead.
The config's render state is authoritative on both backends.blending / depthTest / depthWrite / transparent /
toneMapped / side are resolved ONCE by resolveRenderState and
then applied to whichever material the branch produced, so a TSL
factory's internally-set values are overridden by whatever the host
passed — and by the same resolved defaults when the host passed
nothing. The WebGPU branch used to thread config.uniforms alone
and drop the rest, which silently disabled the bloom upsample pass's
AdditiveBlending: each upsample overwrote its destination mip
instead of accumulating into it, so WebGPU bloom rendered as a flat
dim wash (#2563). Resolving in one place is what stops the two
branches drifting apart for the same config.
defines is deliberately NOT threaded on the WebGPU branch. It is
an OPTIONAL field on three's Material type that Material itself
never initialises (ShaderMaterial and several built-in mesh material
classes do), so a bare NodeMaterial has defines === undefined — and
nothing in three's node pipeline reads it: .defines appears nowhere
in the three.webgpu build, GLSL
fallback included, three's GLSL program builder being its only
consumer. Threading it would therefore be inert as far as three is
concerned. Luxar's *TSLMaterial classes do keep their own flag bag
there (hence their if (!this.defines) this.defines = {} guards), but
they fill it from their factory config, not from this helper.
Two limits on how far the cross-backend agreement reaches:
toneMapped is threaded for symmetry but is only OBSERVED on the
WebGL path — material.toneMapped appears nowhere in the
three.webgpu build either. That backend tone-maps in an output
pass driven by renderer.toneMapping, which PostProcessingManager
pins to NoToneMapping in its constructor because the mega-shader
grades instead.
CustomBlending is expressible but its FACTORS are not: there is
no blendEquation / blendSrc / blendDst here, so such a
request lands on Material's default factors on both backends.
Luxar's max, opaque and gsplat normal states all carry
factors (volumetric delegates to the gsplat-normal helper for
exactly that state) and reach materials through
applyBlendingStateToMaterial (rendering/blending-state.ts),
not through here.
A factory that derives its own COMPLETE blending state therefore
must not be routed through buildMaterial unless the caller passes
that whole state — the resolved defaults overwrite whatever the
config omits.
One limit that is NOT backend-specific, since it is easy to assume
otherwise: premultipliedAlpha is not expressible in the config
either, and SubtractiveBlending / MultiplyBlending require it on
EVERY path — WebGPUPipelineUtils._getBlending, WebGLState and the
WebGL fallback's WebGLState all refuse those two identically,
logging an error and issuing no blend state (on the WebGL path the
cached blending is still marked as applied, so the draw silently
keeps the previous material's blend factors). Both presets are
unusable through this helper on either backend.
buildMaterial— central branching helper for the dual-stack renderer (THREE.WebGLRenderer+WebGPURenderer).A caller delegates inner construction here — today that is the post-processing passes only (
FxaaPass,BloomChain), the geometry materials having their own wrappers. The helper branches onRendererCapabilities.apiSurface:THREE.ShaderMaterialfromsource.webgl.{vertex,fragment}.source.webgpu(uniforms)to get aNodeMaterial.Both
webglandwebgpufields onShaderSourceare optional in the type so future single-backend shaders can opt out cleanly, but the helper throws when the active backend's source is absent. Under WebGPU specifically we do NOT silently fall back to ShaderMaterial —WebGPURenderercannot dispatchShaderMaterialeven when running on its internal WebGL2 backend (seeBROWSER_SUPPORT_POLICY.md), so the silent fallback would render blank quads. Throwing surfaces the gap at construction time instead.The config's render state is authoritative on both backends.
blending/depthTest/depthWrite/transparent/toneMapped/sideare resolved ONCE byresolveRenderStateand then applied to whichever material the branch produced, so a TSL factory's internally-set values are overridden by whatever the host passed — and by the same resolved defaults when the host passed nothing. The WebGPU branch used to threadconfig.uniformsalone and drop the rest, which silently disabled the bloom upsample pass'sAdditiveBlending: each upsample overwrote its destination mip instead of accumulating into it, so WebGPU bloom rendered as a flat dim wash (#2563). Resolving in one place is what stops the two branches drifting apart for the same config.definesis deliberately NOT threaded on the WebGPU branch. It is an OPTIONAL field on three'sMaterialtype thatMaterialitself never initialises (ShaderMaterialand several built-in mesh material classes do), so a bareNodeMaterialhasdefines === undefined— and nothing in three's node pipeline reads it:.definesappears nowhere in thethree.webgpubuild, GLSL fallback included, three's GLSL program builder being its only consumer. Threading it would therefore be inert as far as three is concerned. Luxar's*TSLMaterialclasses do keep their own flag bag there (hence theirif (!this.defines) this.defines = {}guards), but they fill it from their factory config, not from this helper.Two limits on how far the cross-backend agreement reaches:
toneMappedis threaded for symmetry but is only OBSERVED on the WebGL path —material.toneMappedappears nowhere in thethree.webgpubuild either. That backend tone-maps in an output pass driven byrenderer.toneMapping, whichPostProcessingManagerpins toNoToneMappingin its constructor because the mega-shader grades instead.CustomBlendingis expressible but its FACTORS are not: there is noblendEquation/blendSrc/blendDsthere, so such a request lands onMaterial's default factors on both backends. Luxar'smax,opaqueand gsplatnormalstates all carry factors (volumetricdelegates to the gsplat-normalhelper for exactly that state) and reach materials throughapplyBlendingStateToMaterial(rendering/blending-state.ts), not through here.A factory that derives its own COMPLETE blending state therefore must not be routed through
buildMaterialunless the caller passes that whole state — the resolved defaults overwrite whatever the config omits.One limit that is NOT backend-specific, since it is easy to assume otherwise:
premultipliedAlphais not expressible in the config either, andSubtractiveBlending/MultiplyBlendingrequire it on EVERY path —WebGPUPipelineUtils._getBlending,WebGLStateand the WebGL fallback'sWebGLStateall refuse those two identically, logging an error and issuing no blend state (on the WebGL path the cached blending is still marked as applied, so the draw silently keeps the previous material's blend factors). Both presets are unusable through this helper on either backend.