Source code for simvx.core.world_environment

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

from __future__ import annotations

import logging
from typing import Any

import numpy as np

from .descriptors import Property
from .node import Node

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 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") def __init__(self, shader_code: str = "", *, enabled: bool = True, order: int = 0, language: str = "glsl") -> 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 @property def language(self) -> str: """Shader language: 'glsl' (default) or 'wgsl' (WebGPU).""" return self._language @property def shader_code(self) -> str: """Fragment shader source (the body; version/layout is auto-wrapped by the renderer).""" return self._shader_code @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 @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 @order.setter def order(self, value: int) -> None: self._order = value @property def uniforms(self) -> dict[str, Any]: """Read-only copy of current uniform values.""" return dict(self._uniforms) @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 = Property((0.1, 0.1, 0.15, 1.0), group="Ambient") ambient_light_energy = Property(1.0, group="Ambient") ambient_light_mode = Property("colour", group="Ambient") fog_enabled = Property(False, group="Fog") fog_colour = Property((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", group="Tonemap") tonemap_exposure = Property(1.0, group="Tonemap") tonemap_white = Property(1.0, group="Tonemap") bloom_enabled = Property(True, group="Bloom") bloom_threshold = Property(1.0, group="Bloom") bloom_intensity = Property(0.8, group="Bloom") volumetric_fog_enabled = Property(False, group="Fog") volumetric_fog_density = Property(0.05, range=(0.0, 1.0), group="Fog") volumetric_fog_albedo = Property((1.0, 1.0, 1.0, 1.0), hint="Fog scatter colour (RGBA)", group="Fog") volumetric_fog_emission = Property((0.0, 0.0, 0.0, 1.0), hint="Fog self-emission colour (RGBA)", 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") 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") 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") colour_grading_enabled = Property(False, group="Colour Grading") sky_mode = Property("colour", group="Sky") sky_colour_top = Property((0.4, 0.5, 0.9, 1.0), group="Sky") sky_colour_bottom = Property((0.7, 0.8, 1.0, 1.0), group="Sky") sky_texture = 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 @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 = True self.bloom_threshold = 1.0 self.bloom_intensity = 0.8 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 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)