Source code for simvx.core.world_environment

"""WorldEnvironment, Environment, and PostProcessEffect for SimVX."""

import logging
from typing import Any

import numpy as np

from .descriptors import Property
from .math.types import Vec2
from .node import Node
from .properties import Colour

log = logging.getLogger(__name__)


[docs] class PostProcessEffect: """User-defined fullscreen post-processing shader effect. Holds fragment GLSL source, uniform values, enable state, and execution order. The graphics backend compiles the shader and executes it as a fullscreen pass. Standard uniforms provided automatically to every effect: - ``u_time`` (float): elapsed time in seconds - ``u_resolution`` (vec2): screen resolution in pixels - ``u_colour_tex`` (sampler2D): current framebuffer colour - ``u_depth_tex`` (sampler2D): scene depth buffer (sample by normalised UV; under render-scale/TAAU it may be at a lower resolution than the colour) Example:: effect = PostProcessEffect( shader_code=\"\"\" void main() { vec2 uv = gl_FragCoord.xy / u_resolution; vec3 colour = texture(u_colour_tex, uv).rgb; frag_colour = vec4(vec3(dot(colour, vec3(0.299, 0.587, 0.114))), 1.0); } \"\"\", order=10, ) effect.set_uniform("intensity", 0.5) world_env.add_post_process(effect) """ __slots__ = ("_shader_code", "_enabled", "_order", "_uniforms", "_uniform_types", "_dirty", "_language", "_wrap") #: Addressing modes accepted by ``wrap`` for ``u_colour_tex``. WRAP_MODES = ("clamp", "repeat", "mirror") def __init__( self, shader_code: str = "", *, enabled: bool = True, order: int = 0, language: str = "glsl", wrap: str = "clamp", ) -> None: self._shader_code = shader_code self._language = language self._enabled = enabled self._order = order self._uniforms: dict[str, Any] = {} self._uniform_types: dict[str, str] = {} self._dirty = True self._wrap = self._validate_wrap(wrap) @staticmethod def _validate_wrap(value: str) -> str: if value not in PostProcessEffect.WRAP_MODES: raise ValueError(f"wrap must be one of {PostProcessEffect.WRAP_MODES}, got {value!r}") return value
[docs] @property def language(self) -> str: """Shader language: 'glsl' (default) or 'wgsl' (WebGPU).""" return self._language
@property def wrap(self) -> str: """Addressing for ``u_colour_tex`` taps outside [0,1]: 'clamp' (default), 'repeat', or 'mirror'. 'clamp' matches the scene-colour behaviour of the built-in screen-space passes and is correct for edge-neighbourhood sampling. Choose 'repeat'/'mirror' only for an effect that deliberately tiles or reflects the framebuffer (scroll, kaleidoscope). """ return self._wrap
[docs] @wrap.setter def wrap(self, value: str) -> None: value = self._validate_wrap(value) if value != self._wrap: self._wrap = value self._dirty = True
@property def shader_code(self) -> str: """Fragment shader source (the body; version/layout is auto-wrapped by the renderer).""" return self._shader_code
[docs] @shader_code.setter def shader_code(self, value: str) -> None: if value != self._shader_code: self._shader_code = value self._dirty = True
@property def enabled(self) -> bool: return self._enabled
[docs] @enabled.setter def enabled(self, value: bool) -> None: self._enabled = value
@property def order(self) -> int: """Execution priority: lower values run first.""" return self._order
[docs] @order.setter def order(self, value: int) -> None: self._order = value
[docs] @property def uniforms(self) -> dict[str, Any]: """Read-only copy of current uniform values.""" return dict(self._uniforms)
[docs] @property def dirty(self) -> bool: """Whether the shader or uniforms have changed since last GPU sync.""" return self._dirty
[docs] def clear_dirty(self) -> None: """Mark as synced with GPU (called by the renderer).""" self._dirty = False
[docs] def set_uniform(self, name: str, value: Any) -> None: """Set a shader uniform value. Type is inferred from the value. Supported: float, int, vec2/3/4 (tuple or ndarray), mat4 (4x4 ndarray). """ self._uniforms[name] = value if name not in self._uniform_types: self._uniform_types[name] = _infer_pp_uniform_type(value) self._dirty = True
[docs] def get_uniform(self, name: str) -> Any: """Get current uniform value. Raises KeyError if not set.""" return self._uniforms[name]
[docs] def __repr__(self) -> str: state = "on" if self._enabled else "off" return f"<PostProcessEffect order={self._order} {state} uniforms={list(self._uniforms)}>"
def _infer_pp_uniform_type(value: Any) -> str: """Infer GLSL type string from a Python value.""" if isinstance(value, int | np.integer): return "int" if isinstance(value, float | np.floating): return "float" if isinstance(value, np.ndarray): if value.shape == (4, 4): return "mat4" return {2: "vec2", 3: "vec3", 4: "vec4"}.get(value.size, "float") if isinstance(value, tuple | list): return {2: "vec2", 3: "vec3", 4: "vec4"}.get(len(value), "float") return "float"
[docs] class WorldEnvironment(Node): """Global rendering environment settings for a scene.""" ambient_light_colour = Colour((0.1, 0.1, 0.15, 1.0), group="Ambient") # Scales the sky-driven IBL ambient. The default colour-gradient sky is LDR # and fairly bright, so full strength (1.0) floods scenes with fill light; # 0.3 gives a restrained sky tint that keeps the directional light reading. # Raise for stronger sky bounce, 0.0 for flat-ambient only. ambient_light_energy = Property(0.3, group="Ambient") ambient_light_mode = Property("colour", group="Ambient") # Pluggable ambient tier: "probe" keeps today's full layered # ambient (flat fill + sky IBL + reflection-probe blend), "ibl" skips the # probe blend, "flat" is the flat colour fill only. The value is a CEILING: # each tier still self-gates on engine state (no sky IBL -> flat fill even # under "probe"), so the default changes nothing. ambient_mode = Property("probe", enum=["probe", "ibl", "flat"], group="Ambient") # Flat ambient floor for the 2D light pass (rgb added to every fragment # under a PointLight2D, a = unused). The canonical 2D ambient since # CanvasModulate was retired. Default matches the pass's previous built-in # dark-neutral floor so scenes with no env node render unchanged. ambient_light_2d = Colour((0.2, 0.2, 0.2, 1.0), group="Ambient") fog_enabled = Property(False, group="Fog") fog_colour = Colour((0.5, 0.6, 0.7, 1.0), group="Fog") fog_density = Property(0.02, group="Fog") fog_start = Property(10.0, range=(0.0, 1000.0), group="Fog") fog_end = Property(100.0, range=(0.0, 5000.0), group="Fog") fog_mode = Property("exponential", group="Fog") fog_height = Property(0.0, group="Fog") fog_height_density = Property(0.0, group="Fog") tonemap_mode = Property("aces", enum=["aces", "neutral", "reinhard", "uchimura", "linear"], group="Tonemap") tonemap_exposure = Property(1.0, group="Tonemap") tonemap_white = Property(1.0, group="Tonemap") # Opt-in (design pure_2d_effects): bloom is off by default so a bare # WorldEnvironment never silently enters the HDR/bloom path. Enable it # explicitly (3D or 2D); a 2D-only scene that enables it gets glow via the # HDR lane + linear tonemap. bloom_enabled = Property(False, group="Bloom") bloom_threshold = Property(1.0, group="Bloom") bloom_intensity = Property(0.8, group="Bloom") bloom_soft_knee = Property(0.5, range=(0.0, 1.0), group="Bloom") # Quality tier: one string dial resolved through the # authoritative core tier table in ``simvx.core.graphics_quality``. # "auto" (default) is the detect-default: no table, the backend decides # (web resolves a tier from its WebGPU capability probe; desktop runs at # its built-in defaults) and the per-dial Quality Properties below apply # as-is. A named tier drives the wired dials (render_scale, # probe_blend_count, probe_face_size) from the table; a per-dial Property # explicitly moved off its default still wins. "high" is pinned to the # engine defaults, so it renders identically to a default scene. "custom" # is the explicit pass-through: table never consulted, dials are yours. quality_tier = Property("auto", enum=["auto", "low", "medium", "high", "ultra", "custom"], group="Quality") # Reflection-probe quality dials. None = backend # default: desktop blends the top 2 probes per fragment and captures 128 px # cube faces; web takes both dials from its quality-tier table. An explicit # value overrides the default/tier on both backends. probe_blend_count = 0 # disables probe capture + blending entirely (ambient falls back to the sky # IBL / flat fill); face size is the per-face capture resolution (the IBL # convolution output sizes are fixed, so large faces buy little). probe_blend_count = Property(None, range=(0, 8), group="Quality") probe_face_size = Property(None, range=(16, 2048), group="Quality") # Resolution scale of the main-view HDR chain. The # whole chain (HDR colour + depth, SSAO, bloom inputs, volumetric fog, TAA # history, velocity) renders at ceil(output_extent * render_scale); the # tonemap fullscreen draw upscales (bilinear) to the window. 1.0 (default) # renders at native resolution and is byte-identical to the unscaled path. # Below 1.0 trades sharpness for fill-rate; above 1.0 supersamples. # Screen-space 2D (UI, overlays) always draws at native resolution after # the upscale. Both backends; on web the value multiplies the quality # tier's hdrScale (e.g. low tier 0.5 x render_scale 0.5 = quarter res). render_scale = Property(1.0, range=(0.25, 2.0), group="Quality") # Bounds recursive scene-feedback depth for scene targets that sample each # other (e.g. a mirror facing a mirror, SubViewport or RenderView). The # SceneTargetGraph renders producers before consumers in the same frame; # this caps how many times a cyclic feedback chain is re-rendered per frame. # Currently fixed at 1: each target renders once per frame, so a cyclic # back-edge lags by one frame (the natural, never-raising degradation). The # range is pinned to 1 until multi-bounce recursive feedback (rendering a # cyclic target N times per frame for N reflection bounces) is implemented; # see TODO.md. The Property exists now so enabling deeper feedback later # needs no scene migration. scene_feedback_max_depth = Property(1, range=(1, 1), group="Quality") # When True, lit surfaces are tinted by which CSM cascade they sample # (red=near, green=mid, blue=far). Useful for tuning cascade splits. shadow_debug_cascades = Property(False, group="Shadows") # Renderer debug view: a global diagnostic that makes the # 3D shading output a single surface channel instead of the lit result. # "off" (default) is the full lit path, byte-identical to today. Reads from # the thin G-buffer sources where relevant (normal/roughness); albedo/ao from # their material textures; "overdraw" is a per-fragment fill heat; "mip" # ramps the sampled albedo mip level. Both backends. debug_view = Property( "off", enum=["off", "albedo", "normal", "roughness", "ao", "overdraw", "mip"], group="Debug", ) # Active cascade count for directional CSM. The shader/atlas/SSBO ship # at a fixed maximum (currently 3: splits vec4 packs 4 boundaries); # this property selects how many slots carry meaningful frusta each # frame, with unused slots zeroed. shadow_cascade_count = Property(3, range=(1, 3), group="Shadows") volumetric_fog_enabled = Property(False, group="Fog") volumetric_fog_density = Property(0.05, range=(0.0, 1.0), group="Fog") volumetric_fog_albedo = Colour((1.0, 1.0, 1.0, 1.0), group="Fog") volumetric_fog_emission = Colour((0.0, 0.0, 0.0, 1.0), group="Fog") volumetric_fog_anisotropy = Property( 0.2, range=(-1.0, 1.0), hint="Mie scattering direction (-1 back, 0 iso, 1 forward)", group="Fog", ) volumetric_fog_length = Property(64.0, hint="Maximum fog distance from camera", group="Fog") volumetric_fog_gi_inject = Property( 0.0, range=(0.0, 1.0), hint="Global illumination injection strength", group="Fog" ) volumetric_fog_temporal_reprojection = Property(True, group="Fog") ssao_enabled = Property(False, group="SSAO") ssao_radius = Property(0.5, range=(0.0, 5.0), group="SSAO") ssao_bias = Property(0.025, range=(0.0, 0.5), group="SSAO") ssao_intensity = Property(1.0, range=(0.0, 5.0), group="SSAO") # Thin G-buffer real normals for SSAO. When True the renderer # activates a second HDR attachment carrying octahedral world normals, and # SSAO samples them instead of reconstructing from depth derivatives (no # silhouette artefacts). Off by default: the G-buffer is zero-cost when unused. ssao_normals = Property(False, group="SSAO") # Screen-space reflections. Hi-Z traced, half-res by # default; writes reflected radiance to the indirect-specular ambient hook so # glossy surfaces mirror the on-screen scene, falling back to probe/IBL # ambient on a ray miss. Requires the post-process HDR path; activating it # switches on the thin G-buffer automatically. Off by default: the whole pass # is zero-cost and every SSR-off frame is byte-identical. The tier ``ssr_mode`` # ceiling (GraphicsQuality) can force it ``"off"`` regardless of this toggle. ssr_enabled = Property(False, group="Reflections") # Overall strength of the traced reflection (multiplies the reflected colour # before the uber's Fresnel weight). ssr_intensity = Property(1.0, range=(0.0, 2.0), group="Reflections") # Maximum view-space trace distance in world units (longer = reflections reach # farther, more steps). ssr_max_distance = Property(40.0, range=(1.0, 500.0), group="Reflections") # Surfaces rougher than this get no SSR (the blurred reflection would be # dominated by IBL anyway); keeps the trace to glossy materials. ssr_roughness_cutoff = Property(0.6, range=(0.0, 1.0), group="Reflections") # Screen-space global illumination. A half-res Hi-Z # hemisphere trace gathers one-bounce indirect diffuse from the on-screen # scene colour and feeds the pluggable indirect-diffuse ambient hook, # adding coloured light bleed (a red wall tinting a nearby white floor) # on top of the flat/IBL ambient. Temporally accumulated to denoise (ghosting # under fast motion is an accepted tradeoff). Requires the post-process HDR # path + the thin G-buffer (activated automatically). Off by default: the # whole pass is zero-cost and every SSGI-off frame is byte-identical. The tier # ``ssgi_mode`` ceiling (GraphicsQuality) can force it ``"off"``. ssgi_enabled = Property(False, group="Global Illumination") # Overall strength of the gathered bounce (multiplies the indirect diffuse # before the uber's albedo/ao modulation). ssgi_intensity = Property(1.0, range=(0.0, 4.0), group="Global Illumination") # Maximum view-space gather distance in world units. Indirect diffuse is # short range, so this stays small (longer = more steps, wider bleed). ssgi_max_distance = Property(8.0, range=(0.5, 60.0), group="Global Illumination") # Weather globals, published to shaders via the per-frame FrameGlobals UBO. # Wind drives foliage sway / ocean spectra; wetness drives the # rain/puddle darkening + ripple response. All default to calm/dry (strength # and wetness zero) so a scene that ignores them is unaffected and renders # byte-identically. ``wind_direction`` is a normalized-ish XZ heading (packed # as-is; shaders normalize). wind_direction = Property(Vec2(1.0, 0.0), group="Weather") wind_strength = Property(0.0, range=(0.0, 20.0), group="Weather") wind_gustiness = Property(0.0, range=(0.0, 1.0), group="Weather") wetness = Property(0.0, range=(0.0, 1.0), group="Weather") rain_intensity = Property(0.0, range=(0.0, 1.0), group="Weather") ripple_strength = Property(0.0, range=(0.0, 1.0), group="Weather") # Gates GPU Hi-Z occlusion culling. Off by default: building and testing the # hierarchical depth pyramid costs per-frame GPU time that only pays off in # dense, heavily occluded scenes, so it is opt-in rather than always-on. occlusion_culling_enabled = Property(False, group="Culling") # Render execution mode. ``'default'`` is the synchronous path (the main # thread simulates and records+submits the GPU frame inline, byte-identical # to before). ``'pipelined'`` opts into a render thread: the main thread # simulates frame N+1 while a render thread records+submits frame N, bounded # to +1 frame of latency by a 2-slot CPU RenderPacket ring. This is the # authored home for the choice; ``App(render_thread=True)`` is a per-process # override. Off by default because pipelining trades +1 frame latency for # throughput and only the standard-GIL native-call overlap (free-threaded # 3.14t fully overlaps the Python sim). render_mode = Property("default", group="Performance") dof_enabled = Property(False, group="Depth of Field") dof_focus_distance = Property(0.5, range=(0.0, 1000.0), group="Depth of Field") dof_focus_range = Property(0.1, range=(0.0, 100.0), group="Depth of Field") # Maximum circle-of-confusion radius in UV units: caps the blur disc so # high-resolution viewports don't smear the whole screen. 0.02 ≈ 2 % of # the screen width, matching the desktop tonemap.frag DoF default. dof_max_coc = Property(0.02, range=(0.0, 0.2), group="Depth of Field") motion_blur_enabled = Property(False, group="Motion Blur") motion_blur_intensity = Property(1.0, range=(0.0, 5.0), group="Motion Blur") motion_blur_samples = Property(8, group="Motion Blur") film_grain_enabled = Property(False, group="Film Effects") film_grain_intensity = Property(0.05, range=(0.0, 1.0), group="Film Effects") vignette_enabled = Property(False, group="Film Effects") vignette_intensity = Property(0.8, range=(0.0, 2.0), group="Film Effects") vignette_smoothness = Property(0.4, range=(0.0, 1.0), group="Film Effects") chromatic_aberration_enabled = Property(False, group="Film Effects") chromatic_aberration_intensity = Property(0.005, range=(0.0, 0.1), group="Film Effects") # Pure-2D-applicable cross-backend screen-space effects (desktop + web). # CRT/scanlines darken the LDR result by a screen-space sine + barrel mask; # pixelate snaps the sample UV to a block grid; blur is a small box blur of # the input. Each is off by default and gated by its flag bit (zero cost # when unused). They run in the same tonemap pass as vignette/grain. crt_enabled = Property(False, group="Film Effects") crt_intensity = Property(0.4, range=(0.0, 1.0), group="Film Effects") pixelate_enabled = Property(False, group="Film Effects") pixelate_size = Property(4.0, range=(1.0, 64.0), group="Film Effects") blur_enabled = Property(False, group="Film Effects") blur_radius = Property(2.0, range=(0.0, 8.0), group="Film Effects") fxaa_enabled = Property(False, group="Anti-Aliasing") taa_enabled = Property(False, group="Anti-Aliasing") colour_grading_enabled = Property(False, group="Colour Grading") lut_enabled = Property(False, group="Colour Grading") lut_tex_id = Property(0, group="Colour Grading") sky_mode = Property("colour", group="Sky") # Calmer daytime sky: a medium-blue zenith fading to a soft, slightly hazy # horizon (not the near-white it used to be), so the sky background and its # IBL ambient stay restrained rather than flooding the scene. sky_colour_top = Colour((0.30, 0.42, 0.68, 1.0), group="Sky") sky_colour_bottom = Colour((0.50, 0.58, 0.70, 1.0), group="Sky") sky_texture = Property(None, group="Sky") # Dynamic procedural sky. With ``sky_mode="procedural"`` the # renderer synthesizes a Preetham analytic sky cubemap from the first # ``DirectionalLight3D`` (the sun) and drives both the skybox background and # the IBL ambient from it, re-baking incrementally as the sun sweeps. # ``sky_turbidity`` is atmospheric haze (~2 clear .. ~10 hazy); # ``sky_ground_albedo`` shades downward rays as a flat ground bounce. Both # are inert unless ``sky_mode == "procedural"``. sky_turbidity = Property(2.5, group="Sky") sky_ground_albedo = Colour((0.15, 0.15, 0.16, 1.0), group="Sky") # Environment cubemap that drives the skybox + IBL (irradiance + specular # + BRDF). Stored as a ``Resource(package, name)`` reference so the # value survives ``.py`` scene round-trips and editor-time scene # editing without a live GPU device. ``None`` means no IBL: direct # lighting only. May also accept a synthesised ``CubemapHandle`` for # procedural skies, in which case the renderer reuses it directly. environment_map = Property(None, group="Sky") def __init__(self, name: str = "WorldEnvironment", **kwargs): super().__init__(name=name, **kwargs) self._post_processes: list[PostProcessEffect] = [] self._env_dirty = True
[docs] @property def env_dirty(self) -> bool: return self._env_dirty
[docs] def clear_env_dirty(self) -> None: self._env_dirty = False
[docs] def __setattr__(self, name: str, value: Any) -> None: super().__setattr__(name, value) if not name.startswith("_") and isinstance(getattr(type(self), name, None), Property): self._env_dirty = True
[docs] def add_post_process(self, effect: PostProcessEffect) -> None: """Register a custom post-processing effect.""" if effect not in self._post_processes: self._post_processes.append(effect) self._post_processes.sort(key=lambda e: e.order)
[docs] def remove_post_process(self, effect: PostProcessEffect) -> None: """Unregister a custom post-processing effect.""" self._post_processes = [e for e in self._post_processes if e is not effect]
[docs] def get_post_processes(self) -> list[PostProcessEffect]: """Return registered effects sorted by order.""" return list(self._post_processes)
[docs] class Environment: """Environment resource that can be shared between WorldEnvironment nodes.""" def __init__(self): self.ambient_light_colour = (0.1, 0.1, 0.15, 1.0) self.fog_enabled = False self.fog_colour = (0.5, 0.6, 0.7, 1.0) self.fog_density = 0.02 self.tonemap_mode = "aces" self.tonemap_exposure = 1.0 self.bloom_enabled = False self.bloom_threshold = 1.0 self.bloom_intensity = 0.8 self.bloom_soft_knee = 0.5 self.quality_tier = "auto" self.sky_mode = "colour" self.volumetric_fog_enabled = False self.volumetric_fog_density = 0.05 self.volumetric_fog_albedo = (1.0, 1.0, 1.0, 1.0) self.volumetric_fog_emission = (0.0, 0.0, 0.0, 1.0) self.volumetric_fog_anisotropy = 0.2 self.volumetric_fog_length = 64.0 self.volumetric_fog_gi_inject = 0.0 self.volumetric_fog_temporal_reprojection = True # Pure-2D cross-backend screen-space effects (mirror WorldEnvironment). self.crt_enabled = False self.crt_intensity = 0.4 self.pixelate_enabled = False self.pixelate_size = 4.0 self.blur_enabled = False self.blur_radius = 2.0 self._post_processes: list[PostProcessEffect] = [] self.use_clustered_lighting: bool = False self.cluster_depth_slices: int = 24
[docs] def add_post_process(self, effect: PostProcessEffect) -> None: """Register a custom post-processing effect.""" if effect not in self._post_processes: self._post_processes.append(effect) self._post_processes.sort(key=lambda e: e.order)
[docs] def remove_post_process(self, effect: PostProcessEffect) -> None: """Unregister a custom post-processing effect.""" self._post_processes = [e for e in self._post_processes if e is not effect]
[docs] def get_post_processes(self) -> list[PostProcessEffect]: """Return registered effects sorted by order.""" return list(self._post_processes)