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