"""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):
"""Scene-wide rendering settings: ambient, sky, fog, tonemapping and post-processing.
This node is how a scene talks to the renderer. Add one anywhere in the
tree and set its properties; the backend reads them each frame. Never reach
for the renderer directly.
Only the first ``WorldEnvironment`` found in the tree drives the global
settings (the renderer resolves it with ``root.find(WorldEnvironment)``),
so a scene should have exactly one. Custom :class:`PostProcessEffect`
instances are the exception: those are collected from *every*
``WorldEnvironment`` in the tree, so an additive effect can ride along on a
sub-scene's own node.
A scene with no ``WorldEnvironment`` renders at the backend's built-in
defaults; that absence is also what keeps a pure-2D scene out of the HDR
post path entirely. Adding the node is therefore an opt-in, and every
heavyweight feature on it (bloom, SSAO, SSR, SSGI, TAA, volumetric fog,
occlusion culling, depth of field) is off by default so it stays zero-cost
until asked for.
Properties are grouped for the inspector: Ambient, Fog, Tonemap, Bloom,
Quality, Shadows, SSAO, Reflections, Global Illumination, Weather, Film
Effects, Anti-Aliasing, Colour Grading, Sky, Depth of Field, Motion Blur,
Culling, Performance and Debug.
**How ambient works.** Three layers combine, gated by ``ambient_mode``:
- the flat fill ``ambient_light_colour``, which every surface always gets
and which stops an unlit scene going pure black,
- image-based lighting from the sky or ``environment_map``, scaled by
``ambient_light_energy``,
- nearby ``ReflectionProbe3D`` captures, blended over the IBL.
``ambient_mode`` is a ceiling, not a switch: ``"probe"`` (the default)
allows all three, ``"ibl"`` drops the probe blend, ``"flat"`` leaves only
the flat fill. Each layer also self-gates on what the scene actually has,
so a scene with no sky falls back to the flat fill even under ``"probe"``.
**Tuning the look.** ``ambient_light_energy`` and ``tonemap_exposure`` are
the two dials to reach for first: the former decides how much the sky
bleeds into shadowed surfaces, the latter how bright the whole frame lands
before tonemapping (and it composes with ``Camera3D.exposure``).
Example::
env = WorldEnvironment()
env.sky_mode = "procedural" # sun-driven Preetham sky + IBL
env.ambient_light_energy = 0.5 # let more sky bounce into the shade
env.fog_enabled = True
env.bloom_enabled = True
root.add_child(env)
"""
ambient_light_colour = Colour(
(0.1, 0.1, 0.15, 1.0),
hint="Flat ambient fill added to every 3D surface: the floor that keeps unlit areas from going black",
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.
ambient_light_energy = Property(
0.3,
range=(0.0, 4.0),
clamp=False,
hint="Strength of the sky/environment-map ambient (0 = flat fill only, 1 = full sky bounce)",
group="Ambient",
)
ambient_light_mode = Property("colour", group="Ambient")
ambient_mode = Property(
"probe",
enum=["probe", "ibl", "flat"],
hint="Ambient ceiling: probe = flat + sky IBL + reflection probes, ibl = no probes, flat = fill only",
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),
hint="Minimum light every 2D draw receives once the scene has a PointLight2D (alpha unused)",
group="Ambient",
)
fog_enabled = Property(False, hint="Blend distant geometry toward fog_colour (the sky is left alone)", group="Fog")
fog_colour = Colour((0.5, 0.6, 0.7, 1.0), hint="Fog tint, authored in sRGB", group="Fog")
fog_density = Property(
0.02,
range=(0.0, 1.0),
clamp=False,
hint="Thickness for the exponential modes; ignored by fog_mode 'linear'",
group="Fog",
)
fog_start = Property(
10.0,
range=(0.0, 1000.0),
hint="Distance where linear fog begins; ignored by the exponential modes",
group="Fog",
)
fog_end = Property(
100.0,
range=(0.0, 5000.0),
hint="Distance where linear fog reaches full strength; ignored by the exponential modes",
group="Fog",
)
fog_mode = Property(
"exponential",
hint="Falloff curve: 'linear' (fog_start/fog_end), 'exponential' (default), or 'exponential_squared'",
group="Fog",
)
fog_height = Property(
0.0,
hint="World Y plane the height gradient is measured from: fog thickens below it",
group="Fog",
)
fog_height_density = Property(
0.0,
range=(0.0, 1.0),
clamp=False,
hint="Strength of the height gradient; 0 (default) keeps fog uniform with altitude",
group="Fog",
)
tonemap_mode = Property(
"aces",
enum=["aces", "neutral", "reinhard", "uchimura", "linear"],
hint="HDR-to-display curve; 'linear' is a clamp that keeps flat 2D art at its authored colour",
group="Tonemap",
)
tonemap_exposure = Property(
1.0,
range=(0.0, 16.0),
clamp=False,
hint="Scene brightness multiplier before tonemapping; composes with Camera3D.exposure",
group="Tonemap",
)
tonemap_white = Property(
1.0,
range=(0.0001, 16.0),
clamp=False,
hint="Luminance that maps to display white: the Reinhard white point, a white reference elsewhere",
group="Tonemap",
)
# 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,
hint="Bleed light from bright pixels; opts the scene into the HDR path",
group="Bloom",
)
bloom_threshold = Property(
1.0,
range=(0.0, 10.0),
clamp=False,
hint="Luminance a pixel must exceed to glow; 1.0 means only over-bright (HDR) pixels bloom",
group="Bloom",
)
bloom_intensity = Property(
0.8,
range=(0.0, 5.0),
clamp=False,
hint="How strongly the blurred glow is added back over the scene",
group="Bloom",
)
bloom_soft_knee = Property(
0.5,
range=(0.0, 1.0),
hint="Softness of the threshold: 0 is a hard cut, 1 fades pixels in well below the threshold",
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, shadow_caster_count) 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")
# Positional shadow casters: how many point lights, and how many spot
# lights, may cast a shadow at once. None = the backend default (one of
# each) under "auto"/"custom"; under a named quality_tier the table value
# applies instead (low 0, medium 1, high 1, ultra 2). An explicit value
# overrides the default/tier. The count sizes the shadow atlases, so
# changing it reallocates them: set it once for the scene rather than per
# frame. Lights beyond the budget still light the scene, they just cast no
# shadow, and the renderer says so once; which of them keep their shadow is
# decided by PointLight3D/SpotLight3D.shadow_priority and then by how much
# each light is worth to the camera (its brightness and reach against its
# distance), with a light that is already casting keeping its shadow until
# another clearly beats it. 0 turns positional shadows off.
# Each slot adds one row to each atlas, which on Vulkan costs about 20 MiB
# of VRAM: a 3072x512 point row and a 1024x1024 spot row, each carrying an
# R32F distance image and a D32 depth image (8 bytes a texel). So a budget
# of 1 holds about 20 MiB and the maximum of 8 about 160 MiB, whether or not
# the scene has that many casting lights, and a device whose image limits
# cannot fit the rows caps the budget lower and says so. Raising it also
# draws every shadow-casting mesh once more per caster (six times over, for
# a point light), but only on the frames that caster's map changes: a light
# that has not moved, over geometry and materials that have not changed,
# keeps the map it already has, so a still scene pays that once and not per
# frame.
shadow_caster_count = Property(None, range=(0, 8), 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.
# 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, unchanged. 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",
hint="Sky source: 'colour' vertical gradient (default), 'procedural' sun-driven sky, or 'texture'",
group="Sky",
)
# Calmer daytime sky: a medium-blue zenith fading to a soft, slightly hazy
# horizon, 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),
hint="Zenith colour of the gradient sky; also drives the window clear colour",
group="Sky",
)
sky_colour_bottom = Colour((0.50, 0.58, 0.70, 1.0), hint="Horizon colour of the gradient sky", group="Sky")
sky_texture = Property(None, hint="Sky image used when sky_mode is 'texture'", group="Sky")
# ``sky_turbidity`` and ``sky_ground_albedo`` feed the Preetham analytic sky
# and are inert unless ``sky_mode == "procedural"``.
sky_turbidity = Property(
2.5,
range=(1.0, 10.0),
clamp=False,
hint="Atmospheric haze of the procedural sky: ~2 is clear, ~10 is hazy (needs sky_mode 'procedural')",
group="Sky",
)
sky_ground_albedo = Colour(
(0.15, 0.15, 0.16, 1.0),
hint="Ground bounce tint applied to downward rays of the procedural sky",
group="Sky",
)
# 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. May also accept a synthesised ``CubemapHandle`` for procedural
# skies, in which case the renderer reuses it directly.
environment_map = Property(
None,
hint="Environment cubemap driving the skybox and image-based lighting; None = direct lighting only",
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)