"""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, field
from typing import Any
__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}
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 _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 for
# now; desktop computes its ambient_colour fill separately (see the
# desktop-parity plan in BUGS.md / TODO).
EnvField("ambient_light_energy", "post_process.ambient_energy", backends=("web",)),
# 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"),
# 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"),
# Anti-aliasing + LUT colour grading. FXAA wired on both backends;
# TAA + LUT remain web-only today.
EnvField("fxaa_enabled", "post_process.fxaa_enabled"),
EnvField("taa_enabled", "post_process.taa_enabled", backends=("web",)),
EnvField("lut_enabled", "post_process.lut_enabled", backends=("web",)),
EnvField("lut_tex_id", "post_process.lut_tex_id",
transform=int, backends=("web",)),
# 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",)),
)
# --- 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",
# Ambient: currently consumed via shaders, not a renderer attribute.
"ambient_light_colour",
"ambient_light_energy",
"ambient_light_mode",
# Quality tier: runtime hint, not an env→renderer property.
"quality_tier",
# Gating flags surfaced to the colour-grading bridge in the caller.
"colour_grading_enabled",
})
# --- Resolver-driven application -----------------------------------------
@dataclass(slots=True)
class _Bag:
"""Aggregator for backends that batch writes (e.g. method-kwarg style)."""
by_subsystem: dict[str, dict[str, Any]] = field(default_factory=dict)
[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.
"""
for entry in ENV_SYNC_SPEC:
if backend not in entry.backends:
continue
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)