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