"""Single source of truth for ``WorldEnvironment`` → renderer-subsystem field
propagation. Both the Vulkan ``EnvironmentSync`` and the web ``WebApp``
``_sync_world_environment`` walk the same spec; new env fields propagate to
both backends by adding one row.
Composites (e.g. ``camera.exposure × env.tonemap_exposure``) and structurally
divergent bridges (``sky_mode`` → clear-colour / skybox) stay imperative in
the calling sync method: only field-to-attribute propagation lives here.
"""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from .graphics_quality import tier_dial_overrides
__all__ = [
"EnvField",
"ENV_SYNC_SPEC",
"ENV_SYNC_COMPOSITES",
"apply_spec",
]
[docs]
@dataclass(frozen=True, slots=True)
class EnvField:
"""One row of the WorldEnvironment → renderer mapping.
``env_attr`` : name of the attribute on ``WorldEnvironment``.
``target`` : logical ``"subsystem.field"`` path. The backend resolver
maps the subsystem to a concrete object (Vulkan
``Renderer._post_process``, web ``WebRenderer``).
``transform`` : optional callable applied to the env value before write.
Used for enum→int translation, alpha-trim, etc.
``backends`` : backends that should propagate this field. Lets the spec
reflect today's reality where some env fields are wired
on web but not desktop yet (e.g. ``ssao_radius``).
"""
env_attr: str
target: str
transform: Callable[[Any], Any] | None = None
backends: tuple[str, ...] = ("vulkan", "web")
_FOG_MODE_FLOAT = {"linear": 0.0, "exponential": 1.0, "exponential_squared": 2.0}
_FOG_MODE_INT = {"linear": 0, "exponential": 1, "exponential_squared": 2}
_TONEMAP_INT = {"aces": 0, "neutral": 1, "reinhard": 2, "uchimura": 3, "linear": 4}
# Pluggable ambient tier: mirrors the AMBIENT_MODE_* defines in
# cube_textured.frag. 0 (probe = full layered ambient) is today's behaviour.
_AMBIENT_MODE_INT = {"probe": 0, "ibl": 1, "flat": 2}
def _fog_mode_float(m: str) -> float:
return _FOG_MODE_FLOAT.get(m, 1.0)
def _fog_mode_int(m: str) -> int:
return _FOG_MODE_INT.get(m, 1)
def _tonemap_int(m: str) -> int:
return _TONEMAP_INT.get(m, 0)
def _ambient_mode_int(m: str) -> int:
return _AMBIENT_MODE_INT.get(m, 0)
# Renderer debug view: mirrors the DEBUG_VIEW_* defines in
# cube_textured.frag / forward3d.wgsl. 0 (off) is the full lit path.
_DEBUG_VIEW_INT = {"off": 0, "albedo": 1, "normal": 2, "roughness": 3, "ao": 4, "overdraw": 5, "mip": 6}
def _debug_view_int(m: str) -> int:
return _DEBUG_VIEW_INT.get(m, 0)
def _drop_alpha(c: Any) -> tuple:
return tuple(c[:3])
def _to_tuple(c: Any) -> tuple:
return tuple(c)
# --- Spec -----------------------------------------------------------------
#
# Order: bloom, tonemap (mode/white only: exposure is composite), SSAO, DoF,
# motion blur, film effects, fog, volumetric fog, shadows.
ENV_SYNC_SPEC: tuple[EnvField, ...] = (
# Bloom
EnvField("bloom_enabled", "post_process.bloom_enabled"),
EnvField("bloom_threshold", "post_process.bloom_threshold"),
EnvField("bloom_intensity", "post_process.bloom_intensity"),
EnvField("bloom_soft_knee", "post_process.bloom_soft_knee"),
# Tonemap (mode/white). Exposure is composite: handled in caller.
# Mode enumeration is shared with the web WGSL tonemap
# (0=aces, 1=neutral, 2=reinhard, 3=uchimura) so both backends agree.
EnvField("tonemap_mode", "post_process.tonemap_mode", transform=_tonemap_int),
EnvField("tonemap_white", "post_process.tonemap_white"),
# Ambient IBL energy: scales the sky-driven IBL ambient on web (matches the
# intensity multiplier every modern engine exposes). Wired on web only;
# desktop applies it imperatively in environment_sync (ambient_colour.a).
EnvField("ambient_light_energy", "post_process.ambient_energy", backends=("web",)),
# Flat ambient fill colour used when NO skybox is bound (web parity with the
# desktop cube_textured.frag flat fallback). Web only: desktop packs it into
# the shadow buffer's ambient_colour.rgb imperatively in environment_sync.
EnvField("ambient_light_colour", "post_process.ambient_colour", transform=_to_tuple, backends=("web",)),
# 2D light-pass ambient floor (rgb added under every PointLight2D). Routes to
# the Light2DPass on desktop and the WebRenderer's 2D ambient slot on web.
# Replaces the retired CanvasModulate node as the canonical 2D ambient.
EnvField("ambient_light_2d", "light2d.ambient_light_2d", transform=_to_tuple),
# Pluggable ambient tier ceiling: "probe" (full
# layered ambient, today's default), "ibl" (no probe blend), "flat" (flat
# fill only). Desktop reaches cube_textured.frag via the shadow SSBO; web
# rides the post-process wire block into camera.ambient_flags.x.
EnvField("ambient_mode", "renderer.ambient_mode", transform=_ambient_mode_int),
# Reflection-probe quality dials. None = backend
# default (desktop: top-2 blend / 128 px capture faces; web: the
# quality-tier table). Raw values land on the renderer; each backend applies
# its own transition (desktop shader-permutation rebuild + capture-target
# recreation, web probe-block wire override).
EnvField("probe_blend_count", "renderer.probe_blend_count"),
EnvField("probe_face_size", "renderer.probe_face_size"),
# Main-view resolution scale: sizes the whole HDR
# chain at ceil(output_extent * render_scale); the tonemap fullscreen draw
# is the bilinear upscale point on both backends. Desktop lands on the
# Renderer's transition-gated property (an actual change drains the device
# + rebuilds the chain, like a window resize). Web lands on the
# WebRenderer's ``_render_scale`` slot and rides render_info2.x of the
# FrameGlobals wire block; the JS runtime multiplies it into the quality
# tier's hdrScale and reallocates the pooled HDR targets on change.
EnvField("render_scale", "renderer.render_scale"),
# Debug view mode: a global diagnostic that outputs one
# surface channel instead of the lit result. "off" (0) is byte-identical to
# the lit path. Desktop rides the shadow SSBO (offset 368); web rides the
# FrameGlobals wire into the WGSL uber shader. Both backends.
EnvField("debug_view", "renderer.debug_view", transform=_debug_view_int),
# SSAO toggle drives the tonemap FLAG_SSAO on both backends; the tuning
# knobs (radius/bias/intensity) feed the dedicated SSAO subsystem on both
# backends now: desktop maps 'ssao' → renderer._ssao_pass.
EnvField("ssao_enabled", "post_process.ssao_enabled"),
EnvField("ssao_enabled", "ssao_pass.enabled", backends=("vulkan",)),
EnvField("ssao_radius", "ssao.radius"),
EnvField("ssao_bias", "ssao.bias"),
EnvField("ssao_intensity", "ssao.intensity"),
# GPU Hi-Z occlusion culling gate. Plumbing only for now: it lands a plain
# boolean flag on the renderer itself (not a post-process subsystem) so a
# later occlusion pass can read it. Defaults off on both backends.
EnvField("occlusion_culling_enabled", "renderer.occlusion_culling_enabled"),
# Depth of field. Canonical knob is ``dof_max_coc`` (max circle-of-confusion
# in UV units); desktop converts it to a pixel blur radius internally.
EnvField("dof_enabled", "post_process.dof_enabled"),
EnvField("dof_focus_distance", "post_process.dof_focus_distance"),
EnvField("dof_focus_range", "post_process.dof_focus_range"),
EnvField("dof_max_coc", "dof.max_coc"),
# Motion blur
EnvField("motion_blur_enabled", "post_process.motion_blur_enabled"),
EnvField("motion_blur_intensity", "post_process.motion_blur_intensity"),
EnvField("motion_blur_samples", "post_process.motion_blur_samples"),
# Film effects (vignette / chromatic aberration / grain).
# Desktop uses ``grain_*`` on PostProcessPass; web mirrors env naming.
EnvField("film_grain_enabled", "post_process.film_grain_enabled"),
EnvField("film_grain_intensity", "post_process.film_grain_intensity"),
EnvField("vignette_enabled", "post_process.vignette_enabled"),
EnvField("vignette_intensity", "post_process.vignette_intensity"),
EnvField("vignette_smoothness", "post_process.vignette_smoothness"),
EnvField("chromatic_aberration_enabled", "post_process.chromatic_aberration_enabled"),
EnvField("chromatic_aberration_intensity", "post_process.chromatic_aberration_intensity"),
# Pure-2D cross-backend screen-space effects (CRT scanlines / pixelate / box
# blur). Same param + flag-bit scheme on Vulkan (tonemap.frag) and web
# (tonemap3d.wgsl): attribute names match on PostProcessPass and WebRenderer
# (``_`` prefix), so no alias is needed.
EnvField("crt_enabled", "post_process.crt_enabled"),
EnvField("crt_intensity", "post_process.crt_intensity"),
EnvField("pixelate_enabled", "post_process.pixelate_enabled"),
EnvField("pixelate_size", "post_process.pixelate_size"),
EnvField("blur_enabled", "post_process.blur_enabled"),
EnvField("blur_radius", "post_process.blur_radius"),
# Anti-aliasing + LUT colour grading. FXAA + 3D-LUT colour grading wired on
# both backends (the LUT is a 3D rgba8 image sampled post-tonemap, identical
# representation + application point on Vulkan and web). TAA: both backends
# run the full jitter + history resolve. ``post_process.taa_enabled`` drives
# the per-frame camera jitter + current/previous VP capture (web reads it
# too); the second vulkan-only row enables the desktop resolve pass
# (``taa_pass.enabled``) that reprojects + clamps + blends history.
EnvField("fxaa_enabled", "post_process.fxaa_enabled"),
EnvField("taa_enabled", "post_process.taa_enabled", backends=("vulkan", "web")),
EnvField("taa_enabled", "taa_pass.enabled", backends=("vulkan",)),
EnvField("lut_enabled", "post_process.lut_enabled"),
EnvField("lut_tex_id", "post_process.lut_tex_id", transform=int),
# Distance/height fog
EnvField("fog_enabled", "post_process.fog_enabled"),
EnvField("fog_colour", "post_process.fog_colour", transform=_drop_alpha, backends=("vulkan",)),
EnvField("fog_colour", "post_process.fog_colour", transform=_to_tuple, backends=("web",)),
EnvField("fog_density", "post_process.fog_density"),
EnvField("fog_start", "post_process.fog_start"),
EnvField("fog_end", "post_process.fog_end"),
EnvField("fog_mode", "post_process.fog_mode", transform=_fog_mode_float, backends=("vulkan",)),
EnvField("fog_mode", "post_process.fog_mode", transform=_fog_mode_int, backends=("web",)),
# Height fog. On web it feeds the LDR distance-fog path; on desktop it is
# folded into the volumetric fog ray-march (height gradient), which is why
# it targets the volumetric_fog subsystem on vulkan.
EnvField("fog_height", "post_process.fog_height", backends=("web",)),
EnvField("fog_height_density", "post_process.fog_height_density", backends=("web",)),
EnvField("fog_height", "volumetric_fog.fog_height", backends=("vulkan",)),
EnvField("fog_height_density", "volumetric_fog.fog_height_density", backends=("vulkan",)),
# Volumetric fog: single-scatter ray-march on both backends now. Desktop
# routes these to the VolumetricFogPass (HDR pre-tonemap composite); the
# analytic distance-fog branch in tonemap.frag is suppressed while it runs.
EnvField("volumetric_fog_enabled", "volumetric_fog.enabled"),
EnvField("volumetric_fog_density", "volumetric_fog.density"),
EnvField("volumetric_fog_length", "volumetric_fog.length"),
EnvField("volumetric_fog_anisotropy", "volumetric_fog.anisotropy"),
EnvField("volumetric_fog_albedo", "volumetric_fog.albedo", transform=_to_tuple),
EnvField("volumetric_fog_emission", "volumetric_fog.emission", transform=_to_tuple),
EnvField("volumetric_fog_gi_inject", "volumetric_fog.gi_inject"),
# Temporal reprojection is a web-runtime accumulation knob; the desktop
# pass marches per-frame without history, so it stays web-only.
EnvField("volumetric_fog_temporal_reprojection", "volumetric_fog.temporal_reprojection", backends=("web",)),
# Shadows
EnvField("shadow_debug_cascades", "shadow_pass.debug_cascades", transform=bool, backends=("vulkan",)),
EnvField("shadow_cascade_count", "shadow_pass.cascade_count", transform=int, backends=("vulkan",)),
EnvField("shadow_cascade_count", "shadow.cascade_count", transform=int, backends=("web",)),
# Weather: wind + wetness inputs to the FrameGlobals UBO. These do
# not route through a post-process subsystem: they land on the renderer's
# FrameGlobalsEnv holder (desktop) / flat ``_frame_globals_*`` fields (web) and
# are packed into the per-frame UBO. Wired on both backends (desktop set0
# b13, web group0 b1); the web packer reads the same fields.
EnvField("wind_direction", "frame_globals.wind_direction", transform=_to_tuple, backends=("vulkan", "web")),
EnvField("wind_strength", "frame_globals.wind_strength", backends=("vulkan", "web")),
EnvField("wind_gustiness", "frame_globals.wind_gustiness", backends=("vulkan", "web")),
EnvField("wetness", "frame_globals.wetness", backends=("vulkan", "web")),
EnvField("rain_intensity", "frame_globals.rain_intensity", backends=("vulkan", "web")),
EnvField("ripple_strength", "frame_globals.ripple_strength", backends=("vulkan", "web")),
)
# --- Composites + structurally divergent fields --------------------------
#
# These env fields participate in WorldEnvironment sync but cannot be
# expressed as a single attribute write. Listed here for the completeness
# test below: the calling sync method must handle them imperatively.
ENV_SYNC_COMPOSITES: frozenset[str] = frozenset(
{
# Composed with camera.exposure before write.
"tonemap_exposure",
# sky_mode / sky_colour_top / sky_colour_bottom / sky_texture / environment_map
# drive the skybox + clear-colour bridge in the caller.
"sky_mode",
"sky_colour_top",
"sky_colour_bottom",
"sky_texture",
"environment_map",
# Dynamic procedural sky inputs: consumed by the
# procedural-sky bridge in the caller (Preetham cubemap synthesis +
# incremental IBL), not a renderer post-process attribute.
"sky_turbidity",
"sky_ground_albedo",
# Ambient: currently consumed via shaders, not a renderer attribute.
"ambient_light_colour",
"ambient_light_energy",
"ambient_light_mode",
# Quality tier: not a single-attribute write. It is
# resolved through the core tier table INSIDE apply_spec (the wired
# dials' rows carry the tier-resolved values on both backends); the web
# sync additionally lands the tier name on the WebRenderer so the wire
# can flag the env render_scale as absolute (overriding, not
# multiplying, the boot-detected JS tier).
"quality_tier",
# Scene-feedback depth: SceneTargetGraph render-ordering cap read
# directly by the SubViewportManager (both backends), not a
# post-process subsystem field.
"scene_feedback_max_depth",
# Pipelined-render mode: authored hint read directly by App (selects the
# opt-in render-thread path), not synced to a renderer post-process field.
"render_mode",
# Gating flags surfaced to the colour-grading bridge in the caller.
"colour_grading_enabled",
# Thin G-buffer real normals for SSAO: drives the renderer's
# gbuffer activation directly (rebuilds the HDR target + pipelines), not
# a post-process subsystem attribute.
"ssao_normals",
# Screen-space reflections: the enable toggle +
# tuning are consumed by the SSR activation composite in the caller
# (apply_ssr_state, which also folds into the G-buffer activation and
# respects the tier ssr_mode ceiling), not a post-process subsystem field.
"ssr_enabled",
"ssr_intensity",
"ssr_max_distance",
"ssr_roughness_cutoff",
# Screen-space global illumination: the enable toggle +
# tuning are consumed by the SSGI activation composite in the caller
# (apply_ssgi_state, which folds into the same G-buffer activation as SSR
# and respects the tier ssgi_mode ceiling), not a post-process field.
"ssgi_enabled",
"ssgi_intensity",
"ssgi_max_distance",
}
)
# --- Resolver-driven application -----------------------------------------
[docs]
def apply_spec(env: Any, *, backend: str, resolve: Callable[[str, str, Any], None]) -> None:
"""Walk ``ENV_SYNC_SPEC``, read each env attr, write via ``resolve``.
``resolve(subsystem, attr, value)`` does the backend-specific dispatch
(attribute write, method-kwarg accumulation, etc.). Fields whose backend
list excludes the requested ``backend`` are skipped.
Quality-tier resolution happens here so both backends
inherit it from the one choke point: a named ``env.quality_tier`` maps its
wired dials (render_scale, probe_blend_count, probe_face_size) to the core
tier-table values, which then ride the exact same rows and transition
paths as an explicit per-dial Property. "auto"/"custom" yield no overrides
and this reads every attr straight off *env*, the pre-tier behaviour.
"""
overrides = tier_dial_overrides(env)
for entry in ENV_SYNC_SPEC:
if backend not in entry.backends:
continue
if entry.env_attr in overrides:
value = overrides[entry.env_attr]
else:
value = getattr(env, entry.env_attr)
if entry.transform is not None:
value = entry.transform(value)
subsystem, _, attr = entry.target.partition(".")
resolve(subsystem, attr, value)