Source code for simvx.core.env_sync_spec

"""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)