diff --git a/toontown/hood/GZHoodDataAI.py b/toontown/hood/GZHoodDataAI.py index e3f55cc..0e9667c 100644 --- a/toontown/hood/GZHoodDataAI.py +++ b/toontown/hood/GZHoodDataAI.py @@ -151,7 +151,6 @@ class GZHoodDataAI(HoodDataAI.HoodDataAI): self.golfKarts += foundKarts self.golfKartGroups += foundKartGroups - print(self.golfKarts, self.golfKartGroups) for golfKart in self.golfKarts: golfKart.start() self.addDistObj(golfKart) diff --git a/toontown/hood/Hood.py b/toontown/hood/Hood.py index 146c4a5..d24b739 100644 --- a/toontown/hood/Hood.py +++ b/toontown/hood/Hood.py @@ -21,6 +21,7 @@ from toontown.toon.Toon import teleportDebug from toontown.toonbase import ToontownGlobals from toontown.toonbase import TTLocalizer from toontown.toonbase.ToonBaseGlobal import base +from direct.showbase.ShowBaseGlobal import hidden class Hood(StateData): @@ -84,6 +85,44 @@ class Hood(StateData): base.localAvatar.stopChat() + def _loadSkyModel(self, path, halloween=False): + """Load a sky model; if the file is missing use a placeholder. + + Legacy sky BAM files are removed when ProceduralSky is active, so a + missing file is the normal case and logged at debug level only. + """ + # When ProceduralSky is active the old sky BAM files are intentionally + # deleted – don't try to load them at all. + try: + from toontown.hood.SkyUtil import _wantProceduralSky + if _wantProceduralSky(): + sky = NodePath('legacySkyDisabled') + sky.reparentTo(hidden) + sky.setTag('sky', 'Halloween' if halloween else 'Regular') + return sky + except Exception: + pass + sky = base.loader.loadModel(path) + try: + valid = sky is not None and not sky.isEmpty() + except Exception: + valid = False + if not valid: + self.notify.debug('Sky model not found (%r) – ProceduralSky will be used.' % path) + sky = NodePath('missingSkyPlaceholder') + sky.reparentTo(hidden) + sky.setTag('sky', 'Halloween' if halloween else 'Regular') + return sky + sky.setTag('sky', 'Halloween' if halloween else 'Regular') + sky.setScale(1.0) + if not halloween: + sky.setFogOff() + try: + sky.flattenLight() + except Exception: + pass + return sky + def load(self): if self.storageDNAFile: base.loader.loadDNAFile(self.dnaStore, self.storageDNAFile) @@ -96,23 +135,12 @@ class Hood(StateData): base.loader.loadDNAFile(self.dnaStore, storageFile) if ToontownGlobals.HALLOWEEN_COSTUMES not in holidayIds and ToontownGlobals.SPOOKY_COSTUMES not in holidayIds or not self.spookySkyFile: - self.sky = base.loader.loadModel(self.skyFile) - self.sky.setTag('sky', 'Regular') - self.sky.setScale(1.0) - self.sky.setFogOff() - # Flatten sky for better performance - self.sky.flattenLight() + self.sky = self._loadSkyModel(self.skyFile, halloween=False) else: - self.sky = base.loader.loadModel(self.spookySkyFile) - self.sky.setTag('sky', 'Halloween') - self.sky.flattenLight() + self.sky = self._loadSkyModel(self.spookySkyFile, halloween=True) if not newsManager: - self.sky = base.loader.loadModel(self.skyFile) - self.sky.setTag('sky', 'Regular') - self.sky.setScale(1.0) - self.sky.setFogOff() - self.sky.flattenLight() + self.sky = self._loadSkyModel(self.skyFile, halloween=False) def unload(self): if hasattr(self, 'loader'): @@ -241,6 +269,25 @@ class Hood(StateData): messenger.send(self.doneEvent) def startSky(self): + # If the procedural sky shader system is active, suppress the legacy + # model sky entirely so it can never render over the shader dome. + try: + from toontown.hood.SkyUtil import _wantProceduralSky + if _wantProceduralSky(): + try: + if self.sky and not self.sky.isEmpty(): + self.sky.removeNode() + except Exception: + pass + try: + from direct.showbase.ShowBaseGlobal import hidden + self.sky = NodePath('legacySkyDisabled') + self.sky.reparentTo(hidden) + except Exception: + pass + return + except Exception: + pass self.sky.reparentTo(base.camera) self.sky.setZ(0.0) self.sky.setHpr(0.0, 0.0, 0.0) @@ -258,8 +305,14 @@ class Hood(StateData): if hasattr(self, 'sky') and self.sky: self.stopSky() - self.sky = base.loader.loadModel(self.spookySkyFile) - self.sky.setTag('sky', 'Halloween') + # Uses _loadSkyModel so missing/deleted BAMs (replaced by ProceduralSky) do not crash. + self.sky = self._loadSkyModel(self.spookySkyFile, halloween=True) + try: + nm = self.sky.getName() + if nm in ('legacySkyDisabled', 'missingSkyPlaceholder'): + return + except Exception: + pass self.sky.setColor(0.5, 0.5, 0.5, 1) self.sky.reparentTo(base.camera) self.sky.setTransparency(TransparencyAttrib.MDual, 1) @@ -272,10 +325,12 @@ class Hood(StateData): def endSpookySky(self): if hasattr(self, 'sky') and self.sky: - self.sky.reparentTo(base.hidden) + try: + self.sky.reparentTo(base.hidden) + except Exception: + pass - if hasattr(self, 'sky'): - self.sky = base.loader.loadModel(self.skyFile) - self.sky.setTag('sky', 'Regular') - self.sky.setScale(1.0) - self.startSky() + # Never call loader.loadModel(self.skyFile) here — legacy TT_sky.bam et al. may be + # removed when ProceduralSky is enabled; _loadSkyModel returns a safe placeholder. + self.sky = self._loadSkyModel(self.skyFile, halloween=False) + self.startSky() diff --git a/toontown/hood/InteractiveAnimatedProp.py b/toontown/hood/InteractiveAnimatedProp.py index 4eb6bc7..f9e4c26 100644 --- a/toontown/hood/InteractiveAnimatedProp.py +++ b/toontown/hood/InteractiveAnimatedProp.py @@ -51,6 +51,12 @@ class InteractiveAnimatedProp(GenericAnimatedProp.GenericAnimatedProp, FSM.FSM): GenericAnimatedProp.GenericAnimatedProp.__init__(self, node) return + def _isHolidayRunning(self): + newsMgr = getattr(getattr(base, 'cr', None), 'newsManager', None) + if not newsMgr: + return False + return newsMgr.isHolidayRunning(self.holidayId) + def delete(self): self.exit() GenericAnimatedProp.GenericAnimatedProp.delete(self) @@ -202,7 +208,7 @@ class InteractiveAnimatedProp(GenericAnimatedProp.GenericAnimatedProp, FSM.FSM): GenericAnimatedProp.GenericAnimatedProp.enter(self) if base.config.GetBool('props-buff-battles', True): self.notify.debug('props buff battles is true') - if base.cr.newsManager.isHolidayRunning(self.holidayId): + if self._isHolidayRunning(): self.notify.debug('holiday is running, doing idle interval') self.node.stop() self.node.pose('idle0', 0) @@ -343,28 +349,28 @@ class InteractiveAnimatedProp(GenericAnimatedProp.GenericAnimatedProp, FSM.FSM): def gotoFaceoff(self): self.notify.debugStateCall(self) - if base.cr.newsManager.isHolidayRunning(self.holidayId): + if self._isHolidayRunning(): self.request('Faceoff') else: self.notify.debug('not going to faceoff because holiday %d is not running' % self.holidayId) def gotoBattleCheer(self): self.notify.debugStateCall(self) - if base.cr.newsManager.isHolidayRunning(self.holidayId): + if self._isHolidayRunning(): self.request('BattleCheer') else: self.notify.debug('not going to battleCheer because holiday %d is not running' % self.holidayId) def gotoIdle(self): self.notify.debugStateCall(self) - if base.cr.newsManager.isHolidayRunning(self.holidayId): + if self._isHolidayRunning(): self.request('DoIdleAnim') else: self.notify.debug('not going to idle because holiday %d is not running' % self.holidayId) def gotoVictory(self): self.notify.debugStateCall(self) - if base.cr.newsManager.isHolidayRunning(self.holidayId): + if self._isHolidayRunning(): self.request('Victory') else: self.notify.debug('not going to victory because holiday %d is not running' % self.holidayId) @@ -372,7 +378,7 @@ class InteractiveAnimatedProp(GenericAnimatedProp.GenericAnimatedProp, FSM.FSM): def gotoSad(self, buildingDoId): self.notify.debugStateCall(self) self.buildingsMakingMeSad.add(buildingDoId) - if base.cr.newsManager.isHolidayRunning(self.holidayId): + if self._isHolidayRunning(): self.request('Sad') else: self.notify.debug('not going to sad because holiday %d is not running' % self.holidayId) diff --git a/toontown/hood/OutdoorLighting.py b/toontown/hood/OutdoorLighting.py new file mode 100644 index 0000000..16bd737 --- /dev/null +++ b/toontown/hood/OutdoorLighting.py @@ -0,0 +1,3127 @@ +"""Advanced cinematic outdoor lighting system for Toontown. + +Zone lighting profiles – each zone (and street variant) captures a real-world +atmospheric feel: + tt / tt_street – Toontown Central: warm Nebraska afternoon ~3 PM golden hour + dd / dd_street – Donald's Dock: heavy overcast maritime fog, diffuse daylight + dg / dg_street – Daisy's Gardens: blazing Virginia spring noon, intense blue sky + mm / mm_street – Minnie's Melodyland: deep California sunset, orange-purple sky + br / br_street – The Brrrgh: arctic blizzard, pale powder-blue light + dl / dl_street – Donald's Dreamland: moonlit midnight, cool blue-purple starry sky + gs – Goofy Speedway: hazy mid-morning race day, light dusty haze + sellbot_hq – dark purple factory smog + cashbot_hq – sickly green money trainyard + lawbot_hq – cold steel-blue overcast courtroom evening + bossbot_hq – near-pitch-black boardroom with brown ember undertones + factory_int – Sellbot / Lawbot factory interior: dim green-tinted machinery light + mint_int – Cashbot Mint interior: cold metallic pale-green vaults + office_int – Lawbot Office interior: harsh white corridor fluorescents + bossbot_cc – Bossbot Country Club interior: mahogany dimness + warm lamp fill + golf_course – Bossbot Golf Course exterior: verdant sunlit fairway + +New / upgraded systems +──────────────────────── +POST-PROCESS (no full-scene RTT compositor) + • CommonFilters bloom on the main window (adds glow on bright areas). + • Screen-space sun shafts via a lightweight render2dp overlay (sunrays shaders). + • Avoids the custom FilterManager “scene → texture → fullscreen quad” path that + triggered bottom-left viewport bugs on some drivers. + Controlled by lighting-bloom-enabled, lighting-god-rays, zone bloom/ray params. + +PROCEDURAL SKY + • ProceduralSky replaces hood.sky model with a shader-driven atmospheric sky + dome: Rayleigh + Mie scattering, FBM cumulus clouds that react to sunlight, + night-time star field, optional moon disc (Donald's Dreamland). + • Toggle: want-procedural-sky (default True). Falls back to model sky if + shaders unavailable. + +SHADOW SYSTEM + • Full-zone coverage: shadow film size computed from actual scene geometry. + • 2D card / billboard geometry is excluded from the shadow PASS (shadow + camera mask) so flat foliage planes don't cast false block shadows. They + still *receive* shadows normally. + • Transparent texture planes (foliage, building facades) receive and cast + shadows correctly via clearLightOff + a dedicated shadow mask BitMask32. + • Per-zone shadowDarknessFloor prevents pitch-black even in deep shadow. + +DAY / NIGHT CYCLE + • Per-zone keyframe tables; all parameters smoothly interpolated. + • All new parameters (cloud coverage, star brightness, Mie turbidity, etc.) + are also interpolated across the day/night cycle. + • Toggle: want-day-night-cycle. + +BACKWARD COMPATIBILITY + Public API unchanged: begin(), end(), shadeExtraSubtree(), + clearExtraSubtree(), refreshSettings(). + New: setTimeOfDay(), setupWaterReflection(), setWaterReflectionQuality(). +""" + +from __future__ import annotations + +import math +import os +from typing import Any + +from panda3d.core import ( + AmbientLight, + BitMask32, + BillboardEffect, + CardMaker, + ColorBlendAttrib, + ConfigVariableBool, + DepthOffsetAttrib, + DirectionalLight, + Filename, + Fog, + LightAttrib, + Material, + NodePath, + PlaneNode, + PointLight, + RenderState, + Shader, + Texture, + TransparencyAttrib, + Vec3, + Vec4, +) + +from direct.showbase.ShowBaseGlobal import globalClock +from direct.task.TaskManagerGlobal import taskMgr + +from toontown.toonbase.ToonBaseGlobal import base + +# Shader bisect — increment to add the next custom GLSL subsystem: +# 0 = modern outdoor stack off (begin() no-ops; legacy model sky in SkyUtil). +# 1 = procedural sky GLSL only (light rig + fog/tint/bg + sky dome; no HDR post, +# bloom, god rays, or water GLSL). +# 2 = + post-process (see _LEVEL2_POST_SINGLE; one subsystem at a time for debugging). +# 3 = + water reflection GLSL (full pipeline). +_OUTDOOR_SHADER_BISECT_LEVEL = 3 + +# When bisect level is 2, enable exactly one post FX: +# 'full' / 'stack' — bloom + sun-ray overlay (default cinematic post). +# 'bloom' — CommonFilters bloom only. +# 'godrays' — sunrays card on render2dp only. +# Level 3+ always uses full post (bloom + god rays). +_LEVEL2_POST_SINGLE = 'full' + + +def _fixPostProcessRttViewports() -> None: + """Normalize every DisplayRegion on FilterManager offscreen buffers. + + Stock FilterManager.renderSceneInto uses ``buffer.makeDisplayRegion()`` with no + bounds; on some GL drivers that yields a sub-rectangle (often bottom-left + quarter). The scene is then rendered into only part of the colour texture, + while the compositing quad still samples the full texture — exactly the + broken layout players see. Forcing (0,1)×(0,1) on those regions fixes it. + + NOTE: _bloomFilters is a CommonFilters instance, NOT a FilterManager. + CommonFilters stores its FilterManager at _bloomFilters.manager — so + buffers live at _bloomFilters.manager.buffers, not _bloomFilters.buffers. + Getting this wrong means the bloom RTT buffers are never repaired. + """ + # Collect FilterManager instances that own offscreen buffers (bloom, etc.). + fms_to_fix = [] + if _bloomFilters is not None: + # _bloomFilters is a CommonFilters whose internal FilterManager is at .manager + inner = getattr(_bloomFilters, 'manager', None) + if inner is not None: + fms_to_fix.append(inner) + + for fm in fms_to_fix: + try: + for buf in getattr(fm, 'buffers', None) or []: + if buf is None: + continue + n = buf.getNumDisplayRegions() + for i in range(n): + dr = buf.getDisplayRegion(i) + if not dr: + continue + dr.setDimensions(0.0, 1.0, 0.0, 1.0) + if dr.supportsPixelZoom(): + dr.setPixelZoom(1) + try: + dr.setScissorEnabled(False) + except Exception: + pass + except Exception: + pass + +# ───────────────────────────────────────────────────────────────────────────── +# Zone lighting profiles +# ───────────────────────────────────────────────────────────────────────────── +_ZONE_PROFILES: dict[str, dict] = { + + # ── Toontown Central playground ─────────────────────────────────────────── + 'tt': { + 'ambient': (0.22, 0.27, 0.40, 1.0), + 'key': (1.22, 1.08, 0.78, 1.0), + 'keyHpr': (135, -42, 0), + 'fill': (0.24, 0.32, 0.50, 1.0), + 'fillHpr': (-45, -18, 0), + 'rim': None, + 'shadowCaster': True, + 'shadowRes': 4096, + 'shadowArea': 160, + 'shadowResScale': 0.75, + 'shadowAreaScale': 1.40, + 'shadowFollowDist': 280.0, + 'shadowDarknessFloor': 0.18, + 'fogColor': (0.72, 0.80, 0.92, 1.0), + 'fogNear': 80.0, + 'fogFar': 440.0, + 'fogExponent': None, + 'skyScale': (1.04, 1.01, 0.91, 1.0), + 'clearColor': (0.50, 0.68, 0.90, 1.0), + 'sunUV': (0.65, 0.78), + 'rayColor': (1.00, 0.94, 0.68, 1.0), + 'rayIntensity': 0.38, + 'bloomIntensity': 0.32, + 'bloomThreshold': 0.62, + 'exposure': 1.00, + 'hasWater': True, + 'waterColor': (0.28, 0.52, 0.72, 0.88), + 'waterReflQuality': 'medium', + 'dayNightEnabled': True, + 'cloudCoverage': 0.42, + 'cloudSpeed': 0.70, + 'cloudSharpness': 0.55, + 'turbidity': 2.5, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── TT streets ──────────────────────────────────────────────────────────── + 'tt_street': { + 'ambient': (0.20, 0.25, 0.38, 1.0), + 'key': (1.18, 1.05, 0.75, 1.0), + 'keyHpr': (140, -40, 0), + 'fill': (0.22, 0.30, 0.48, 1.0), + 'fillHpr': (-50, -20, 0), + 'rim': None, + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 140, + 'shadowResScale': 1.00, + 'shadowAreaScale': 1.20, + 'shadowFollowDist': 220.0, + 'shadowDarknessFloor': 0.16, + 'fogColor': (0.70, 0.78, 0.90, 1.0), + 'fogNear': 70.0, + 'fogFar': 380.0, + 'fogExponent': None, + 'skyScale': (1.04, 1.01, 0.91, 1.0), + 'clearColor': (0.48, 0.66, 0.88, 1.0), + 'sunUV': (0.65, 0.78), + 'rayColor': (1.00, 0.92, 0.66, 1.0), + 'rayIntensity': 0.30, + 'bloomIntensity': 0.28, + 'bloomThreshold': 0.64, + 'exposure': 1.00, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.40, + 'cloudSpeed': 0.68, + 'cloudSharpness': 0.55, + 'turbidity': 2.4, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Donald's Dock playground ────────────────────────────────────────────── + 'dd': { + 'ambient': (0.60, 0.63, 0.68, 1.0), + 'key': (0.70, 0.74, 0.80, 1.0), + 'keyHpr': (0, -80, 0), + 'fill': (0.52, 0.56, 0.62, 1.0), + 'fillHpr': (180, -55, 0), + 'rim': None, + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 180, + 'shadowDarknessFloor': 0.22, + 'fogColor': (0.72, 0.76, 0.82, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.011, + 'skyScale': (0.84, 0.87, 0.92, 1.0), + 'clearColor': (0.70, 0.74, 0.80, 1.0), + 'sunUV': (0.50, 0.88), + 'rayColor': (0.90, 0.93, 0.98, 1.0), + 'rayIntensity': 0.00, + 'bloomIntensity': 0.18, + 'bloomThreshold': 0.68, + 'exposure': 0.95, + 'hasWater': True, + 'waterColor': (0.22, 0.32, 0.44, 0.90), + 'waterReflQuality': 'high', + 'dayNightEnabled': True, + 'cloudCoverage': 0.88, + 'cloudSpeed': 1.10, + 'cloudSharpness': 0.20, + 'turbidity': 5.5, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── DD streets ──────────────────────────────────────────────────────────── + 'dd_street': { + 'ambient': (0.58, 0.61, 0.66, 1.0), + 'key': (0.68, 0.72, 0.78, 1.0), + 'keyHpr': (0, -78, 0), + 'fill': (0.50, 0.54, 0.60, 1.0), + 'fillHpr': (180, -52, 0), + 'rim': None, + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 140, + 'shadowDarknessFloor': 0.20, + 'fogColor': (0.70, 0.74, 0.80, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.014, + 'skyScale': (0.84, 0.87, 0.92, 1.0), + 'clearColor': (0.68, 0.72, 0.78, 1.0), + 'sunUV': (0.50, 0.85), + 'rayColor': (0.88, 0.92, 0.96, 1.0), + 'rayIntensity': 0.00, + 'bloomIntensity': 0.15, + 'bloomThreshold': 0.70, + 'exposure': 0.93, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.90, + 'cloudSpeed': 1.20, + 'cloudSharpness': 0.18, + 'turbidity': 5.8, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Daisy's Gardens playground ──────────────────────────────────────────── + 'dg': { + 'ambient': (0.18, 0.25, 0.42, 1.0), + 'key': (1.18, 1.08, 0.88, 1.0), + 'keyHpr': (170, -68, 0), + 'fill': (0.22, 0.32, 0.52, 1.0), + 'fillHpr': (-10, -22, 0), + 'rim': (0.10, 0.16, 0.30, 1.0), + 'rimHpr': (0, 55, 0), + 'shadowCaster': True, + 'shadowRes': 4096, + 'shadowArea': 155, + 'shadowResScale': 1.00, + 'shadowAreaScale': 0.95, + 'shadowFollowDist': 260.0, + 'shadowDarknessFloor': 0.16, + 'fogColor': (0.62, 0.80, 1.00, 1.0), + 'fogNear': 120.0, + 'fogFar': 540.0, + 'fogExponent': None, + 'skyScale': (0.88, 0.96, 1.14, 1.0), + 'clearColor': (0.36, 0.62, 1.00, 1.0), + 'sunUV': (0.54, 0.90), + 'rayColor': (1.00, 1.00, 0.88, 1.0), + 'rayIntensity': 0.58, + 'bloomIntensity': 0.42, + 'bloomThreshold': 0.58, + 'exposure': 1.05, + 'hasWater': True, + 'waterColor': (0.24, 0.54, 0.36, 0.84), + 'waterReflQuality': 'medium', + 'dayNightEnabled': True, + 'cloudCoverage': 0.28, + 'cloudSpeed': 0.55, + 'cloudSharpness': 0.70, + 'turbidity': 1.8, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── DG streets ──────────────────────────────────────────────────────────── + 'dg_street': { + 'ambient': (0.16, 0.23, 0.40, 1.0), + 'key': (1.15, 1.05, 0.85, 1.0), + 'keyHpr': (172, -66, 0), + 'fill': (0.20, 0.30, 0.50, 1.0), + 'fillHpr': (-12, -20, 0), + 'rim': (0.08, 0.14, 0.28, 1.0), + 'rimHpr': (0, 52, 0), + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 130, + 'shadowResScale': 1.00, + 'shadowAreaScale': 1.10, + 'shadowFollowDist': 220.0, + 'shadowDarknessFloor': 0.14, + 'fogColor': (0.60, 0.78, 0.98, 1.0), + 'fogNear': 100.0, + 'fogFar': 480.0, + 'fogExponent': None, + 'skyScale': (0.88, 0.96, 1.14, 1.0), + 'clearColor': (0.34, 0.60, 0.98, 1.0), + 'sunUV': (0.54, 0.88), + 'rayColor': (1.00, 1.00, 0.88, 1.0), + 'rayIntensity': 0.50, + 'bloomIntensity': 0.38, + 'bloomThreshold': 0.60, + 'exposure': 1.05, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.24, + 'cloudSpeed': 0.52, + 'cloudSharpness': 0.72, + 'turbidity': 1.8, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Minnie's Melodyland playground ─────────────────────────────────────── + 'mm': { + 'ambient': (0.42, 0.28, 0.30, 1.0), + 'key': (1.10, 0.50, 0.18, 1.0), + 'keyHpr': (260, -14, 0), + 'fill': (0.22, 0.18, 0.40, 1.0), + 'fillHpr': (80, -28, 0), + 'rim': (0.90, 0.40, 0.12, 1.0), + 'rimHpr': (258, -8, 0), + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 200, + 'shadowResScale': 0.85, + 'shadowAreaScale': 1.55, + 'shadowFollowDist': 260.0, + 'shadowDarknessFloor': 0.20, + 'fogColor': (0.80, 0.44, 0.20, 1.0), + 'fogNear': 10.0, + 'fogFar': 330.0, + 'fogExponent': None, + 'skyScale': (1.30, 0.72, 0.48, 1.0), + 'clearColor': (0.88, 0.46, 0.18, 1.0), + 'sunUV': (0.10, 0.43), + 'rayColor': (1.00, 0.58, 0.22, 1.0), + 'rayIntensity': 0.70, + 'bloomIntensity': 0.55, + 'bloomThreshold': 0.55, + 'exposure': 1.02, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.55, + 'cloudSpeed': 0.85, + 'cloudSharpness': 0.45, + 'turbidity': 3.5, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── MM streets ──────────────────────────────────────────────────────────── + 'mm_street': { + 'ambient': (0.40, 0.26, 0.28, 1.0), + 'key': (1.06, 0.48, 0.16, 1.0), + 'keyHpr': (258, -12, 0), + 'fill': (0.20, 0.16, 0.38, 1.0), + 'fillHpr': (78, -26, 0), + 'rim': (0.86, 0.38, 0.10, 1.0), + 'rimHpr': (256, -6, 0), + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 160, + 'shadowResScale': 0.85, + 'shadowAreaScale': 1.40, + 'shadowFollowDist': 220.0, + 'shadowDarknessFloor': 0.18, + 'fogColor': (0.78, 0.42, 0.18, 1.0), + 'fogNear': 8.0, + 'fogFar': 280.0, + 'fogExponent': None, + 'skyScale': (1.30, 0.72, 0.48, 1.0), + 'clearColor': (0.86, 0.44, 0.16, 1.0), + 'sunUV': (0.10, 0.42), + 'rayColor': (1.00, 0.56, 0.20, 1.0), + 'rayIntensity': 0.62, + 'bloomIntensity': 0.50, + 'bloomThreshold': 0.56, + 'exposure': 1.02, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.52, + 'cloudSpeed': 0.88, + 'cloudSharpness': 0.42, + 'turbidity': 3.6, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── The Brrrgh playground ───────────────────────────────────────────────── + 'br': { + 'ambient': (0.42, 0.50, 0.68, 1.0), + 'key': (0.62, 0.72, 0.90, 1.0), + 'keyHpr': (180, -18, 0), + 'fill': (0.38, 0.46, 0.62, 1.0), + 'fillHpr': (0, -12, 0), + 'rim': (0.48, 0.56, 0.74, 1.0), + 'rimHpr': (178, -6, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 180, + 'shadowDarknessFloor': 0.24, + 'fogColor': (0.68, 0.78, 0.94, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.016, + 'skyScale': (0.78, 0.88, 1.08, 1.0), + 'clearColor': (0.62, 0.74, 0.92, 1.0), + 'sunUV': (0.50, 0.44), + 'rayColor': (0.82, 0.90, 1.00, 1.0), + 'rayIntensity': 0.08, + 'bloomIntensity': 0.20, + 'bloomThreshold': 0.70, + 'exposure': 0.90, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.78, + 'cloudSpeed': 1.40, + 'cloudSharpness': 0.15, + 'turbidity': 6.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── BR streets ──────────────────────────────────────────────────────────── + 'br_street': { + 'ambient': (0.40, 0.48, 0.66, 1.0), + 'key': (0.60, 0.70, 0.88, 1.0), + 'keyHpr': (180, -16, 0), + 'fill': (0.36, 0.44, 0.60, 1.0), + 'fillHpr': (0, -10, 0), + 'rim': (0.46, 0.54, 0.72, 1.0), + 'rimHpr': (178, -4, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 140, + 'shadowDarknessFloor': 0.22, + 'fogColor': (0.66, 0.76, 0.92, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.020, + 'skyScale': (0.78, 0.88, 1.08, 1.0), + 'clearColor': (0.60, 0.72, 0.90, 1.0), + 'sunUV': (0.50, 0.42), + 'rayColor': (0.80, 0.88, 1.00, 1.0), + 'rayIntensity': 0.06, + 'bloomIntensity': 0.16, + 'bloomThreshold': 0.72, + 'exposure': 0.88, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.80, + 'cloudSpeed': 1.50, + 'cloudSharpness': 0.12, + 'turbidity': 6.2, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Donald's Dreamland playground ───────────────────────────────────────── + 'dl': { + 'ambient': (0.06, 0.08, 0.16, 1.0), + 'key': (0.40, 0.46, 0.68, 1.0), + 'keyHpr': (225, -55, 0), + 'fill': (0.04, 0.06, 0.12, 1.0), + 'fillHpr': (45, -15, 0), + 'rim': (0.30, 0.36, 0.58, 1.0), + 'rimHpr': (220, -50, 0), + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 170, + 'shadowResScale': 0.75, + 'shadowAreaScale': 1.45, + 'shadowFollowDist': 260.0, + 'shadowDarknessFloor': 0.08, + 'fogColor': (0.05, 0.07, 0.14, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.006, + 'skyScale': (0.42, 0.48, 0.74, 1.0), + 'clearColor': (0.03, 0.04, 0.10, 1.0), + 'sunUV': (0.30, 0.82), + 'rayColor': (0.58, 0.70, 1.00, 1.0), + 'rayIntensity': 0.32, + 'bloomIntensity': 0.22, + 'bloomThreshold': 0.70, + 'exposure': 1.20, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.22, + 'cloudSpeed': 0.25, + 'cloudSharpness': 0.50, + 'turbidity': 1.5, + 'starBrightness': 0.92, + 'moonEnabled': True, + 'moonDir': (225, -55, 0), + }, + + # ── DL streets ──────────────────────────────────────────────────────────── + 'dl_street': { + 'ambient': (0.05, 0.07, 0.14, 1.0), + 'key': (0.38, 0.44, 0.65, 1.0), + 'keyHpr': (225, -52, 0), + 'fill': (0.03, 0.05, 0.10, 1.0), + 'fillHpr': (42, -14, 0), + 'rim': (0.28, 0.34, 0.55, 1.0), + 'rimHpr': (222, -48, 0), + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 140, + 'shadowResScale': 0.75, + 'shadowAreaScale': 1.30, + 'shadowFollowDist': 220.0, + 'shadowDarknessFloor': 0.06, + 'fogColor': (0.04, 0.06, 0.12, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.008, + 'skyScale': (0.42, 0.48, 0.74, 1.0), + 'clearColor': (0.03, 0.04, 0.10, 1.0), + 'sunUV': (0.30, 0.80), + 'rayColor': (0.56, 0.68, 1.00, 1.0), + 'rayIntensity': 0.26, + 'bloomIntensity': 0.18, + 'bloomThreshold': 0.72, + 'exposure': 1.22, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.20, + 'cloudSpeed': 0.22, + 'cloudSharpness': 0.48, + 'turbidity': 1.5, + 'starBrightness': 0.88, + 'moonEnabled': True, + 'moonDir': (225, -55, 0), + }, + + # ── Goofy Speedway ──────────────────────────────────────────────────────── + 'gs': { + 'ambient': (0.30, 0.32, 0.40, 1.0), + 'key': (1.10, 1.00, 0.82, 1.0), + 'keyHpr': (130, -48, 0), + 'fill': (0.24, 0.28, 0.40, 1.0), + 'fillHpr': (-50, -30, 0), + 'rim': None, + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 180, + 'shadowResScale': 0.90, + 'shadowAreaScale': 1.20, + 'shadowFollowDist': 240.0, + 'shadowDarknessFloor': 0.20, + 'fogColor': (0.78, 0.76, 0.68, 1.0), + 'fogNear': 60.0, + 'fogFar': 380.0, + 'fogExponent': None, + 'skyScale': (1.06, 1.02, 0.84, 1.0), + 'clearColor': (0.56, 0.64, 0.72, 1.0), + 'sunUV': (0.60, 0.74), + 'rayColor': (1.00, 0.92, 0.70, 1.0), + 'rayIntensity': 0.26, + 'bloomIntensity': 0.24, + 'bloomThreshold': 0.64, + 'exposure': 1.00, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.32, + 'cloudSpeed': 0.90, + 'cloudSharpness': 0.50, + 'turbidity': 3.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Golf Course (Bossbot) exterior ──────────────────────────────────────── + 'golf_course': { + 'ambient': (0.24, 0.32, 0.28, 1.0), + 'key': (1.20, 1.12, 0.88, 1.0), + 'keyHpr': (160, -58, 0), + 'fill': (0.20, 0.30, 0.22, 1.0), + 'fillHpr': (-20, -28, 0), + 'rim': (0.12, 0.18, 0.10, 1.0), + 'rimHpr': (0, 48, 0), + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 200, + 'shadowResScale': 1.00, + 'shadowAreaScale': 1.30, + 'shadowFollowDist': 280.0, + 'shadowDarknessFloor': 0.18, + 'fogColor': (0.60, 0.78, 0.60, 1.0), + 'fogNear': 100.0, + 'fogFar': 520.0, + 'fogExponent': None, + 'skyScale': (0.88, 1.00, 0.82, 1.0), + 'clearColor': (0.40, 0.68, 0.44, 1.0), + 'sunUV': (0.56, 0.88), + 'rayColor': (1.00, 1.00, 0.80, 1.0), + 'rayIntensity': 0.50, + 'bloomIntensity': 0.38, + 'bloomThreshold': 0.60, + 'exposure': 1.05, + 'hasWater': True, + 'waterColor': (0.28, 0.60, 0.40, 0.82), + 'waterReflQuality': 'medium', + 'dayNightEnabled': False, + 'cloudCoverage': 0.30, + 'cloudSpeed': 0.65, + 'cloudSharpness': 0.60, + 'turbidity': 2.2, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Sellbot HQ (exterior) ───────────────────────────────────────────────── + 'sellbot_hq': { + 'ambient': (0.08, 0.05, 0.14, 1.0), + 'key': (0.22, 0.15, 0.34, 1.0), + 'keyHpr': (135, -65, 0), + 'fill': (0.06, 0.04, 0.10, 1.0), + 'fillHpr': (-60, -30, 0), + 'rim': (0.14, 0.08, 0.22, 1.0), + 'rimHpr': (130, -60, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 180, + 'shadowDarknessFloor': 0.06, + 'fogColor': (0.12, 0.08, 0.18, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.020, + 'skyScale': (0.58, 0.38, 0.80, 1.0), + 'clearColor': (0.08, 0.05, 0.14, 1.0), + 'sunUV': (0.50, 0.50), + 'rayColor': (0.60, 0.38, 0.92, 1.0), + 'rayIntensity': 0.04, + 'bloomIntensity': 0.06, + 'bloomThreshold': 0.82, + 'exposure': 1.30, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.95, + 'cloudSpeed': 0.30, + 'cloudSharpness': 0.05, + 'turbidity': 8.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Sellbot Factory interior ────────────────────────────────────────────── + 'factory_int': { + 'ambient': (0.08, 0.12, 0.06, 1.0), + 'key': (0.35, 0.50, 0.28, 1.0), + 'keyHpr': (90, -75, 0), + 'fill': (0.06, 0.10, 0.04, 1.0), + 'fillHpr': (-80, -40, 0), + 'rim': (0.12, 0.20, 0.08, 1.0), + 'rimHpr': (88, -70, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 120, + 'shadowDarknessFloor': 0.06, + 'fogColor': (0.04, 0.08, 0.02, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.030, + 'skyScale': (0.42, 0.62, 0.30, 1.0), + 'clearColor': (0.03, 0.05, 0.02, 1.0), + 'sunUV': (0.50, 0.50), + 'rayColor': (0.40, 0.70, 0.20, 1.0), + 'rayIntensity': 0.02, + 'bloomIntensity': 0.10, + 'bloomThreshold': 0.60, + 'exposure': 1.40, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.0, + 'cloudSpeed': 0.0, + 'cloudSharpness': 0.0, + 'turbidity': 1.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Cashbot HQ (exterior) ───────────────────────────────────────────────── + 'cashbot_hq': { + 'ambient': (0.06, 0.10, 0.06, 1.0), + 'key': (0.28, 0.40, 0.18, 1.0), + 'keyHpr': (90, -50, 0), + 'fill': (0.04, 0.08, 0.04, 1.0), + 'fillHpr': (-80, -25, 0), + 'rim': (0.24, 0.32, 0.10, 1.0), + 'rimHpr': (88, -45, 0), + 'shadowCaster': True, + 'shadowRes': 1024, + 'shadowArea': 160, + 'shadowResScale': 0.75, + 'shadowAreaScale': 1.20, + 'shadowFollowDist': 210.0, + 'shadowDarknessFloor': 0.06, + 'fogColor': (0.08, 0.14, 0.06, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.012, + 'skyScale': (0.48, 0.82, 0.38, 1.0), + 'clearColor': (0.06, 0.12, 0.04, 1.0), + 'sunUV': (0.50, 0.65), + 'rayColor': (0.68, 1.00, 0.38, 1.0), + 'rayIntensity': 0.08, + 'bloomIntensity': 0.24, + 'bloomThreshold': 0.58, + 'exposure': 1.25, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.85, + 'cloudSpeed': 0.40, + 'cloudSharpness': 0.10, + 'turbidity': 7.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Cashbot Mint interior ───────────────────────────────────────────────── + 'mint_int': { + 'ambient': (0.06, 0.10, 0.08, 1.0), + 'key': (0.32, 0.50, 0.38, 1.0), + 'keyHpr': (90, -80, 0), + 'fill': (0.04, 0.08, 0.06, 1.0), + 'fillHpr': (-80, -40, 0), + 'rim': (0.10, 0.16, 0.12, 1.0), + 'rimHpr': (88, -75, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 100, + 'shadowDarknessFloor': 0.05, + 'fogColor': (0.02, 0.06, 0.04, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.025, + 'skyScale': (0.38, 0.68, 0.48, 1.0), + 'clearColor': (0.02, 0.05, 0.03, 1.0), + 'sunUV': (0.50, 0.50), + 'rayColor': (0.30, 0.80, 0.50, 1.0), + 'rayIntensity': 0.02, + 'bloomIntensity': 0.08, + 'bloomThreshold': 0.55, + 'exposure': 1.45, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.0, + 'cloudSpeed': 0.0, + 'cloudSharpness': 0.0, + 'turbidity': 1.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Lawbot HQ (exterior) ────────────────────────────────────────────────── + 'lawbot_hq': { + 'ambient': (0.08, 0.10, 0.22, 1.0), + 'key': (0.28, 0.36, 0.58, 1.0), + 'keyHpr': (180, -55, 0), + 'fill': (0.06, 0.08, 0.18, 1.0), + 'fillHpr': (0, -30, 0), + 'rim': (0.10, 0.14, 0.30, 1.0), + 'rimHpr': (178, -50, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 180, + 'shadowDarknessFloor': 0.08, + 'fogColor': (0.18, 0.22, 0.42, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.014, + 'skyScale': (0.50, 0.62, 0.94, 1.0), + 'clearColor': (0.12, 0.16, 0.34, 1.0), + 'sunUV': (0.50, 0.70), + 'rayColor': (0.58, 0.78, 1.00, 1.0), + 'rayIntensity': 0.04, + 'bloomIntensity': 0.09, + 'bloomThreshold': 0.75, + 'exposure': 1.20, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.92, + 'cloudSpeed': 0.20, + 'cloudSharpness': 0.10, + 'turbidity': 7.5, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Lawbot Office interior ──────────────────────────────────────────────── + 'office_int': { + 'ambient': (0.18, 0.22, 0.28, 1.0), + 'key': (0.65, 0.72, 0.88, 1.0), + 'keyHpr': (0, -85, 0), + 'fill': (0.14, 0.18, 0.24, 1.0), + 'fillHpr': (180, -60, 0), + 'rim': (0.10, 0.14, 0.20, 1.0), + 'rimHpr': (90, -45, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 100, + 'shadowDarknessFloor': 0.15, + 'fogColor': (0.12, 0.16, 0.24, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.018, + 'skyScale': (0.56, 0.68, 0.88, 1.0), + 'clearColor': (0.10, 0.14, 0.22, 1.0), + 'sunUV': (0.50, 0.50), + 'rayColor': (0.70, 0.82, 1.00, 1.0), + 'rayIntensity': 0.03, + 'bloomIntensity': 0.12, + 'bloomThreshold': 0.65, + 'exposure': 1.15, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.0, + 'cloudSpeed': 0.0, + 'cloudSharpness': 0.0, + 'turbidity': 1.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Bossbot HQ (exterior) ───────────────────────────────────────────────── + 'bossbot_hq': { + 'ambient': (0.03, 0.02, 0.02, 1.0), + 'key': (0.12, 0.07, 0.03, 1.0), + 'keyHpr': (90, -8, 0), + 'fill': (0.04, 0.03, 0.06, 1.0), + 'fillHpr': (-90, -15, 0), + 'rim': (0.06, 0.06, 0.11, 1.0), + 'rimHpr': (270, -20, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 180, + 'shadowDarknessFloor': 0.02, + 'fogColor': (0.02, 0.01, 0.01, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.024, + 'skyScale': (0.20, 0.14, 0.10, 1.0), + 'clearColor': (0.01, 0.01, 0.02, 1.0), + 'sunUV': (0.50, 0.50), + 'rayColor': (0.40, 0.22, 0.10, 1.0), + 'rayIntensity': 0.00, + 'bloomIntensity': 0.00, + 'bloomThreshold': 0.99, + 'exposure': 1.50, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.99, + 'cloudSpeed': 0.10, + 'cloudSharpness': 0.05, + 'turbidity': 8.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Bossbot Country Club interior ───────────────────────────────────────── + 'bossbot_cc': { + 'ambient': (0.10, 0.06, 0.04, 1.0), + 'key': (0.50, 0.38, 0.22, 1.0), + 'keyHpr': (90, -60, 0), + 'fill': (0.08, 0.05, 0.03, 1.0), + 'fillHpr': (-80, -35, 0), + 'rim': (0.18, 0.12, 0.06, 1.0), + 'rimHpr': (88, -55, 0), + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 100, + 'shadowDarknessFloor': 0.08, + 'fogColor': (0.08, 0.04, 0.02, 1.0), + 'fogNear': 0.0, + 'fogFar': None, + 'fogExponent': 0.018, + 'skyScale': (0.40, 0.28, 0.16, 1.0), + 'clearColor': (0.05, 0.03, 0.01, 1.0), + 'sunUV': (0.50, 0.50), + 'rayColor': (0.80, 0.55, 0.25, 1.0), + 'rayIntensity': 0.06, + 'bloomIntensity': 0.14, + 'bloomThreshold': 0.60, + 'exposure': 1.35, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.0, + 'cloudSpeed': 0.0, + 'cloudSharpness': 0.0, + 'turbidity': 1.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + + # ── Generic fallbacks ───────────────────────────────────────────────────── + 'playground': { + 'ambient': (0.28, 0.32, 0.44, 1.0), + 'key': (0.98, 0.92, 0.76, 1.0), + 'keyHpr': (118, -52, 0), + 'fill': (0.18, 0.25, 0.38, 1.0), + 'fillHpr': (-48, -42, 0), + 'rim': None, + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 180, + 'shadowResScale': 0.90, + 'shadowAreaScale': 1.20, + 'shadowFollowDist': 240.0, + 'shadowDarknessFloor': 0.18, + 'fogColor': (0.65, 0.78, 0.94, 1.0), + 'fogNear': 100.0, + 'fogFar': 460.0, + 'fogExponent': None, + 'skyScale': (1.00, 1.00, 1.00, 1.0), + 'clearColor': (0.42, 0.62, 0.84, 1.0), + 'sunUV': (0.62, 0.76), + 'rayColor': (1.00, 0.95, 0.74, 1.0), + 'rayIntensity': 0.28, + 'bloomIntensity': 0.28, + 'bloomThreshold': 0.64, + 'exposure': 1.00, + 'hasWater': False, + 'dayNightEnabled': True, + 'cloudCoverage': 0.35, + 'cloudSpeed': 0.65, + 'cloudSharpness': 0.50, + 'turbidity': 2.4, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + 'estate': { + 'ambient': (0.30, 0.30, 0.40, 1.0), + 'key': (1.00, 0.94, 0.80, 1.0), + 'keyHpr': (105, -48, 0), + 'fill': (0.22, 0.28, 0.38, 1.0), + 'fillHpr': (-55, -38, 0), + 'rim': None, + 'shadowCaster': True, + 'shadowRes': 2048, + 'shadowArea': 180, + 'shadowResScale': 0.90, + 'shadowAreaScale': 1.20, + 'shadowFollowDist': 240.0, + 'shadowDarknessFloor': 0.20, + 'fogColor': (0.68, 0.80, 0.94, 1.0), + 'fogNear': 100.0, + 'fogFar': 470.0, + 'fogExponent': None, + 'skyScale': (1.00, 1.00, 1.00, 1.0), + 'clearColor': (0.44, 0.64, 0.86, 1.0), + 'sunUV': (0.58, 0.74), + 'rayColor': (1.00, 0.95, 0.80, 1.0), + 'rayIntensity': 0.22, + 'bloomIntensity': 0.22, + 'bloomThreshold': 0.66, + 'exposure': 1.00, + 'hasWater': True, + 'waterColor': (0.26, 0.46, 0.64, 0.86), + 'waterReflQuality': 'low', + 'dayNightEnabled': True, + 'cloudCoverage': 0.38, + 'cloudSpeed': 0.60, + 'cloudSharpness': 0.55, + 'turbidity': 2.2, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, + 'cog': { + 'ambient': (0.32, 0.34, 0.40, 1.0), + 'key': (0.82, 0.84, 0.92, 1.0), + 'keyHpr': (90, -45, 0), + 'fill': (0.16, 0.20, 0.28, 1.0), + 'fillHpr': (-75, -30, 0), + 'rim': None, + 'shadowCaster': False, + 'shadowRes': 512, + 'shadowArea': 180, + 'shadowDarknessFloor': 0.18, + 'fogColor': (0.40, 0.42, 0.50, 1.0), + 'fogNear': 80.0, + 'fogFar': 360.0, + 'fogExponent': None, + 'skyScale': (0.82, 0.84, 0.90, 1.0), + 'clearColor': (0.32, 0.36, 0.46, 1.0), + 'sunUV': (0.50, 0.70), + 'rayColor': (0.88, 0.90, 1.00, 1.0), + 'rayIntensity': 0.12, + 'bloomIntensity': 0.10, + 'bloomThreshold': 0.70, + 'exposure': 1.00, + 'hasWater': False, + 'dayNightEnabled': False, + 'cloudCoverage': 0.75, + 'cloudSpeed': 0.50, + 'cloudSharpness': 0.20, + 'turbidity': 6.0, + 'starBrightness': 0.0, + 'moonEnabled': False, + }, +} + +# ───────────────────────────────────────────────────────────────────────────── +# Day / Night keyframe tables (unchanged from previous version) +# ───────────────────────────────────────────────────────────────────────────── +_DAY_NIGHT_KEYFRAMES: dict[str, list[tuple]] = { + 'tt': [ + (0.0, {'ambient': (0.04,0.06,0.14,1), 'key': (0.13,0.16,0.34,1), + 'keyHpr': (225,-38,0), 'fill': (0.03,0.04,0.10,1), + 'rim': (0.18,0.22,0.48,1), 'rimHpr': (222,-35,0), + 'fogColor': (0.04,0.06,0.14,1), 'fogNear': 20.0, 'fogFar': 290.0, + 'clearColor': (0.03,0.05,0.12,1), 'skyScale': (0.40,0.45,0.70,1), + 'rayColor': (0.58,0.70,1.00,1), 'rayIntensity': 0.20, + 'bloomIntensity': 0.14, 'shadowCaster': False, + 'starBrightness': 0.88, 'moonEnabled': True, 'moonDir': (180,-52,0), + 'exposure': 1.35}), + (5.5, {'ambient': (0.08,0.08,0.16,1), 'key': (0.22,0.18,0.36,1), + 'keyHpr': (80,-5,0), 'fill': (0.06,0.06,0.14,1), + 'rim': (0.20,0.16,0.38,1), 'rimHpr': (82,-4,0), + 'fogColor': (0.10,0.12,0.28,1), 'fogNear': 40.0, 'fogFar': 330.0, + 'clearColor': (0.08,0.10,0.24,1), 'skyScale': (0.50,0.52,0.78,1), + 'rayColor': (0.70,0.62,0.90,1), 'rayIntensity': 0.10, + 'bloomIntensity': 0.12, 'shadowCaster': False, + 'starBrightness': 0.28, 'moonEnabled': True, 'moonDir': (265,-38,0), + 'exposure': 1.20}), + (6.5, {'ambient': (0.22,0.16,0.18,1), 'key': (0.90,0.48,0.28,1), + 'keyHpr': (90,-10,0), 'fill': (0.18,0.14,0.28,1), + 'rim': (0.72,0.36,0.18,1), 'rimHpr': (88,-8,0), + 'fogColor': (0.82,0.52,0.32,1), 'fogNear': 60.0, 'fogFar': 390.0, + 'clearColor': (0.72,0.40,0.22,1), 'skyScale': (1.20,0.80,0.58,1), + 'rayColor': (1.00,0.62,0.30,1), 'rayIntensity': 0.52, + 'bloomIntensity': 0.46, 'shadowCaster': True, + 'starBrightness': 0.0, 'exposure': 1.05}), + (9.0, {'ambient': (0.20,0.24,0.36,1), 'key': (1.10,0.96,0.74,1), + 'keyHpr': (120,-36,0), 'fill': (0.20,0.28,0.44,1), 'rim': None, + 'fogColor': (0.68,0.78,0.92,1), 'fogNear': 90.0, 'fogFar': 450.0, + 'clearColor': (0.48,0.66,0.88,1), 'skyScale': (1.04,1.00,0.92,1), + 'rayColor': (1.00,0.90,0.64,1), 'rayIntensity': 0.30, + 'bloomIntensity': 0.30, 'shadowCaster': True, + 'starBrightness': 0.0, 'exposure': 1.00}), + (12.0, {'ambient': (0.24,0.30,0.46,1), 'key': (1.28,1.18,0.98,1), + 'keyHpr': (180,-86,0), 'fill': (0.24,0.34,0.52,1), 'rim': None, + 'fogColor': (0.74,0.83,0.95,1), 'fogNear': 100.0, 'fogFar': 510.0, + 'clearColor': (0.52,0.72,0.96,1), 'skyScale': (1.00,1.00,0.96,1), + 'rayColor': (1.00,0.96,0.80,1), 'rayIntensity': 0.28, + 'bloomIntensity': 0.36, 'shadowCaster': True, + 'starBrightness': 0.0, 'exposure': 1.00}), + (15.0, {'ambient': (0.22,0.27,0.40,1), 'key': (1.22,1.08,0.78,1), + 'keyHpr': (135,-42,0), 'fill': (0.24,0.32,0.50,1), 'rim': None, + 'fogColor': (0.72,0.80,0.92,1), 'fogNear': 80.0, 'fogFar': 440.0, + 'clearColor': (0.50,0.68,0.90,1), 'skyScale': (1.04,1.01,0.91,1), + 'rayColor': (1.00,0.94,0.68,1), 'rayIntensity': 0.38, + 'bloomIntensity': 0.32, 'shadowCaster': True, + 'starBrightness': 0.0, 'exposure': 1.00}), + (17.5, {'ambient': (0.32,0.24,0.26,1), 'key': (1.20,0.72,0.32,1), + 'keyHpr': (230,-22,0), 'fill': (0.22,0.18,0.36,1), + 'rim': (0.82,0.40,0.16,1), 'rimHpr': (228,-18,0), + 'fogColor': (0.86,0.58,0.34,1), 'fogNear': 50.0, 'fogFar': 370.0, + 'clearColor': (0.80,0.50,0.24,1), 'skyScale': (1.22,0.84,0.58,1), + 'rayColor': (1.00,0.64,0.26,1), 'rayIntensity': 0.64, + 'bloomIntensity': 0.56, 'shadowCaster': True, + 'starBrightness': 0.0, 'exposure': 1.02}), + (19.0, {'ambient': (0.22,0.14,0.28,1), 'key': (0.62,0.30,0.42,1), + 'keyHpr': (262,-8,0), 'fill': (0.16,0.10,0.28,1), + 'rim': (0.50,0.22,0.36,1), 'rimHpr': (260,-5,0), + 'fogColor': (0.40,0.20,0.36,1), 'fogNear': 20.0, 'fogFar': 270.0, + 'clearColor': (0.30,0.14,0.32,1), 'skyScale': (0.90,0.58,0.80,1), + 'rayColor': (0.90,0.46,0.72,1), 'rayIntensity': 0.36, + 'bloomIntensity': 0.42, 'shadowCaster': False, + 'starBrightness': 0.12, 'moonEnabled': False, 'exposure': 1.15}), + (21.0, {'ambient': (0.05,0.07,0.16,1), 'key': (0.14,0.18,0.38,1), + 'keyHpr': (225,-40,0), 'fill': (0.03,0.04,0.10,1), + 'rim': (0.20,0.24,0.52,1), 'rimHpr': (222,-38,0), + 'fogColor': (0.04,0.06,0.14,1), 'fogNear': 20.0, 'fogFar': 285.0, + 'clearColor': (0.03,0.04,0.11,1), 'skyScale': (0.38,0.42,0.68,1), + 'rayColor': (0.56,0.68,1.00,1), 'rayIntensity': 0.22, + 'bloomIntensity': 0.15, 'shadowCaster': False, + 'starBrightness': 0.84, 'moonEnabled': True, 'moonDir': (90,-55,0), + 'exposure': 1.32}), + (24.0, None), + ], + 'dd': [ + (0.0, {'ambient': (0.38,0.40,0.46,1), 'key': (0.42,0.46,0.52,1), + 'keyHpr': (0,-70,0), 'fogColor': (0.30,0.34,0.42,1), + 'fogExponent': 0.014, 'clearColor': (0.28,0.32,0.40,1), + 'skyScale': (0.58,0.62,0.70,1), 'bloomIntensity': 0.10}), + (7.0, {'ambient': (0.52,0.54,0.58,1), 'key': (0.60,0.64,0.70,1), + 'keyHpr': (0,-75,0), 'fogColor': (0.60,0.64,0.70,1), + 'fogExponent': 0.011, 'clearColor': (0.58,0.62,0.70,1), + 'skyScale': (0.78,0.82,0.88,1), 'bloomIntensity': 0.15}), + (13.0, {'ambient': (0.62,0.65,0.70,1), 'key': (0.72,0.76,0.82,1), + 'keyHpr': (0,-80,0), 'fogColor': (0.72,0.76,0.82,1), + 'fogExponent': 0.010, 'clearColor': (0.70,0.74,0.80,1), + 'skyScale': (0.84,0.87,0.92,1), 'bloomIntensity': 0.20}), + (20.0, {'ambient': (0.44,0.46,0.52,1), 'key': (0.50,0.52,0.60,1), + 'keyHpr': (270,-72,0), 'fogColor': (0.42,0.46,0.54,1), + 'fogExponent': 0.013, 'clearColor': (0.40,0.44,0.52,1), + 'skyScale': (0.70,0.72,0.80,1), 'bloomIntensity': 0.12}), + (24.0, None), + ], + 'dg': [ + (0.0, {'ambient': (0.04,0.07,0.12,1), 'key': (0.16,0.20,0.40,1), + 'keyHpr': (225,-38,0), 'fill': (0.03,0.05,0.09,1), + 'rim': (0.14,0.18,0.36,1), 'rimHpr': (222,-34,0), + 'fogColor': (0.05,0.08,0.18,1), 'fogNear': 10.0, 'fogFar': 280.0, + 'clearColor': (0.04,0.06,0.14,1), 'skyScale': (0.34,0.42,0.68,1), + 'rayColor': (0.56,0.70,1.00,1), 'rayIntensity': 0.18, + 'bloomIntensity': 0.14, 'shadowCaster': False, + 'starBrightness': 0.76, 'moonEnabled': True, 'moonDir': (180,-55,0)}), + (6.0, {'ambient': (0.20,0.16,0.18,1), 'key': (0.86,0.46,0.22,1), + 'keyHpr': (90,-8,0), 'fill': (0.16,0.12,0.26,1), + 'rim': (0.68,0.34,0.14,1), 'rimHpr': (88,-6,0), + 'fogColor': (0.80,0.50,0.28,1), 'fogNear': 50.0, 'fogFar': 360.0, + 'clearColor': (0.70,0.38,0.18,1), 'skyScale': (1.18,0.76,0.52,1), + 'rayColor': (1.00,0.60,0.28,1), 'rayIntensity': 0.50, + 'bloomIntensity': 0.44, 'shadowCaster': True, + 'starBrightness': 0.0, 'moonEnabled': False}), + (11.0, {'ambient': (0.18,0.25,0.42,1), 'key': (1.20,1.10,0.90,1), + 'keyHpr': (180,-82,0), 'fill': (0.22,0.32,0.52,1), + 'rim': (0.10,0.16,0.30,1), 'rimHpr': (0,55,0), + 'fogColor': (0.62,0.80,1.00,1), 'fogNear': 120.0, 'fogFar': 540.0, + 'clearColor': (0.36,0.62,1.00,1), 'skyScale': (0.88,0.96,1.14,1), + 'rayColor': (1.00,1.00,0.88,1), 'rayIntensity': 0.58, + 'bloomIntensity': 0.44, 'shadowCaster': True, + 'starBrightness': 0.0, 'moonEnabled': False}), + (17.0, {'ambient': (0.28,0.22,0.24,1), 'key': (1.10,0.70,0.30,1), + 'keyHpr': (235,-20,0), 'fill': (0.20,0.16,0.32,1), + 'rim': (0.78,0.36,0.14,1), 'rimHpr': (232,-16,0), + 'fogColor': (0.84,0.56,0.32,1), 'fogNear': 40.0, 'fogFar': 360.0, + 'clearColor': (0.76,0.46,0.20,1), 'skyScale': (1.18,0.82,0.54,1), + 'rayColor': (1.00,0.62,0.24,1), 'rayIntensity': 0.60, + 'bloomIntensity': 0.52, 'shadowCaster': True, + 'starBrightness': 0.0, 'moonEnabled': False}), + (20.0, {'ambient': (0.06,0.08,0.18,1), 'key': (0.18,0.22,0.44,1), + 'keyHpr': (225,-40,0), 'fill': (0.04,0.06,0.12,1), + 'rim': (0.22,0.26,0.52,1), 'rimHpr': (222,-38,0), + 'fogColor': (0.05,0.08,0.18,1), 'fogNear': 10.0, 'fogFar': 280.0, + 'clearColor': (0.04,0.06,0.14,1), 'skyScale': (0.36,0.44,0.70,1), + 'rayColor': (0.56,0.70,1.00,1), 'rayIntensity': 0.20, + 'bloomIntensity': 0.16, 'shadowCaster': False, + 'starBrightness': 0.42, 'moonEnabled': True, 'moonDir': (120,-48,0)}), + (24.0, None), + ], + 'mm': [ + (0.0, {'ambient': (0.24,0.10,0.22,1), 'key': (0.26,0.14,0.36,1), + 'keyHpr': (270,-5,0), 'fill': (0.12,0.06,0.22,1), + 'rim': (0.30,0.14,0.28,1), 'rimHpr': (268,-3,0), + 'fogColor': (0.22,0.10,0.28,1), 'fogNear': 5.0, 'fogFar': 200.0, + 'clearColor': (0.16,0.08,0.24,1), 'skyScale': (0.60,0.28,0.56,1), + 'rayColor': (0.72,0.32,0.80,1), 'rayIntensity': 0.24, + 'bloomIntensity': 0.28, 'shadowCaster': False, + 'starBrightness': 0.58, 'moonEnabled': True, 'moonDir': (220,-42,0)}), + (6.0, {'ambient': (0.36,0.22,0.16,1), 'key': (0.80,0.38,0.10,1), + 'keyHpr': (90,-10,0), 'fill': (0.18,0.12,0.26,1), + 'rim': (0.60,0.28,0.10,1), 'rimHpr': (88,-8,0), + 'fogColor': (0.72,0.36,0.14,1), 'fogNear': 10.0, 'fogFar': 290.0, + 'clearColor': (0.66,0.32,0.12,1), 'skyScale': (1.14,0.60,0.34,1), + 'rayColor': (1.00,0.52,0.18,1), 'rayIntensity': 0.60, + 'bloomIntensity': 0.52, 'shadowCaster': True, + 'starBrightness': 0.0, 'moonEnabled': False}), + (14.0, {'ambient': (0.42,0.28,0.30,1), 'key': (1.10,0.50,0.18,1), + 'keyHpr': (260,-14,0), 'fill': (0.22,0.18,0.40,1), + 'rim': (0.90,0.40,0.12,1), 'rimHpr': (258,-8,0), + 'fogColor': (0.80,0.44,0.20,1), 'fogNear': 10.0, 'fogFar': 330.0, + 'clearColor': (0.88,0.46,0.18,1), 'skyScale': (1.30,0.72,0.48,1), + 'rayColor': (1.00,0.58,0.22,1), 'rayIntensity': 0.70, + 'bloomIntensity': 0.56, 'shadowCaster': True, + 'starBrightness': 0.0, 'moonEnabled': False}), + (20.0, {'ambient': (0.28,0.12,0.32,1), 'key': (0.52,0.22,0.50,1), + 'keyHpr': (270,-6,0), 'fill': (0.14,0.08,0.28,1), + 'rim': (0.44,0.18,0.42,1), 'rimHpr': (268,-4,0), + 'fogColor': (0.34,0.14,0.38,1), 'fogNear': 5.0, 'fogFar': 220.0, + 'clearColor': (0.26,0.10,0.34,1), 'skyScale': (0.80,0.40,0.80,1), + 'rayColor': (0.80,0.38,0.90,1), 'rayIntensity': 0.32, + 'bloomIntensity': 0.38, 'shadowCaster': False, + 'starBrightness': 0.24, 'moonEnabled': True, 'moonDir': (240,-36,0)}), + (24.0, None), + ], + 'br': [ + (0.0, {'ambient': (0.28,0.36,0.54,1), 'key': (0.40,0.50,0.70,1), + 'keyHpr': (180,-10,0), 'fogColor': (0.40,0.52,0.70,1), + 'fogExponent': 0.020, 'clearColor': (0.36,0.46,0.66,1), + 'skyScale': (0.60,0.72,0.94,1), 'bloomIntensity': 0.12}), + (12.0, {'ambient': (0.44,0.52,0.70,1), 'key': (0.64,0.74,0.92,1), + 'keyHpr': (180,-18,0), 'fogColor': (0.68,0.78,0.94,1), + 'fogExponent': 0.016, 'clearColor': (0.62,0.74,0.92,1), + 'skyScale': (0.78,0.88,1.08,1), 'bloomIntensity': 0.22}), + (18.0, {'ambient': (0.32,0.38,0.58,1), 'key': (0.48,0.56,0.78,1), + 'keyHpr': (270,-12,0), 'fogColor': (0.50,0.60,0.80,1), + 'fogExponent': 0.018, 'clearColor': (0.46,0.58,0.78,1), + 'skyScale': (0.68,0.80,1.02,1), 'bloomIntensity': 0.16}), + (24.0, None), + ], + 'dl': [ + (0.0, {'ambient': (0.06,0.08,0.16,1), 'key': (0.40,0.46,0.68,1), + 'keyHpr': (225,-55,0), 'fill': (0.04,0.06,0.12,1), + 'rim': (0.30,0.36,0.58,1), 'rimHpr': (220,-50,0), + 'fogColor': (0.05,0.07,0.14,1), 'fogExponent': 0.006, + 'clearColor': (0.03,0.04,0.10,1), 'skyScale': (0.42,0.48,0.74,1), + 'rayColor': (0.58,0.70,1.00,1), 'rayIntensity': 0.32, + 'bloomIntensity': 0.20, 'shadowCaster': True}), + (3.5, {'ambient': (0.04,0.05,0.12,1), 'key': (0.20,0.24,0.48,1), + 'keyHpr': (270,-20,0), 'fill': (0.02,0.03,0.08,1), + 'rim': (0.16,0.20,0.44,1), 'rimHpr': (268,-18,0), + 'fogColor': (0.03,0.04,0.10,1), 'fogExponent': 0.008, + 'clearColor': (0.02,0.02,0.07,1), 'skyScale': (0.28,0.34,0.60,1), + 'rayColor': (0.46,0.58,0.90,1), 'rayIntensity': 0.16, + 'bloomIntensity': 0.12, 'shadowCaster': False}), + (5.0, {'ambient': (0.08,0.06,0.16,1), 'key': (0.24,0.18,0.44,1), + 'keyHpr': (80,-6,0), 'fill': (0.06,0.04,0.12,1), + 'rim': (0.20,0.14,0.40,1), 'rimHpr': (78,-4,0), + 'fogColor': (0.08,0.06,0.18,1), 'fogExponent': 0.007, + 'clearColor': (0.06,0.05,0.16,1), 'skyScale': (0.40,0.36,0.70,1), + 'rayColor': (0.62,0.52,0.90,1), 'rayIntensity': 0.14, + 'bloomIntensity': 0.14, 'shadowCaster': False}), + (12.0, {'ambient': (0.10,0.12,0.22,1), 'key': (0.38,0.42,0.64,1), + 'keyHpr': (180,-50,0), 'fill': (0.06,0.08,0.16,1), + 'rim': (0.28,0.32,0.56,1), 'rimHpr': (178,-46,0), + 'fogColor': (0.07,0.09,0.18,1), 'fogExponent': 0.006, + 'clearColor': (0.06,0.07,0.16,1), 'skyScale': (0.48,0.54,0.80,1), + 'rayColor': (0.56,0.68,1.00,1), 'rayIntensity': 0.28, + 'bloomIntensity': 0.18, 'shadowCaster': True}), + (20.0, {'ambient': (0.06,0.08,0.16,1), 'key': (0.38,0.44,0.66,1), + 'keyHpr': (135,-42,0), 'fill': (0.04,0.06,0.12,1), + 'rim': (0.28,0.34,0.56,1), 'rimHpr': (132,-38,0), + 'fogColor': (0.05,0.07,0.14,1), 'fogExponent': 0.006, + 'clearColor': (0.03,0.04,0.10,1), 'skyScale': (0.42,0.48,0.74,1), + 'rayColor': (0.58,0.70,1.00,1), 'rayIntensity': 0.30, + 'bloomIntensity': 0.20, 'shadowCaster': True}), + (24.0, None), + ], + 'gs': [ + (0.0, {'ambient': (0.10,0.12,0.20,1), 'key': (0.18,0.20,0.36,1), + 'keyHpr': (225,-32,0), 'fogColor': (0.14,0.16,0.26,1), + 'fogNear': 20.0, 'fogFar': 280.0, 'clearColor': (0.12,0.14,0.22,1), + 'bloomIntensity': 0.12, 'shadowCaster': False, + 'starBrightness': 0.78, 'moonEnabled': True, 'moonDir': (0,-60,0)}), + (7.0, {'ambient': (0.28,0.26,0.24,1), 'key': (0.88,0.58,0.26,1), + 'keyHpr': (90,-12,0), 'fogColor': (0.72,0.60,0.42,1), + 'fogNear': 50.0, 'fogFar': 320.0, 'clearColor': (0.62,0.50,0.34,1), + 'bloomIntensity': 0.36, 'shadowCaster': True, + 'starBrightness': 0.0, 'moonEnabled': False}), + (11.0, {'ambient': (0.30,0.32,0.40,1), 'key': (1.10,1.00,0.82,1), + 'keyHpr': (130,-48,0), 'fogColor': (0.78,0.76,0.68,1), + 'fogNear': 60.0, 'fogFar': 380.0, 'clearColor': (0.56,0.64,0.72,1), + 'bloomIntensity': 0.26, 'shadowCaster': True, + 'starBrightness': 0.0, 'moonEnabled': False}), + (18.0, {'ambient': (0.28,0.22,0.18,1), 'key': (1.00,0.58,0.22,1), + 'keyHpr': (240,-18,0), 'fogColor': (0.72,0.46,0.24,1), + 'fogNear': 40.0, 'fogFar': 320.0, 'clearColor': (0.66,0.40,0.18,1), + 'bloomIntensity': 0.44, 'shadowCaster': True, + 'starBrightness': 0.0, 'moonEnabled': False}), + (21.0, {'ambient': (0.10,0.12,0.20,1), 'key': (0.20,0.22,0.40,1), + 'keyHpr': (225,-34,0), 'fogColor': (0.12,0.14,0.24,1), + 'fogNear': 20.0, 'fogFar': 280.0, 'clearColor': (0.10,0.12,0.20,1), + 'bloomIntensity': 0.14, 'shadowCaster': False, + 'starBrightness': 0.68, 'moonEnabled': True, 'moonDir': (60,-56,0)}), + (24.0, None), + ], +} + +# ───────────────────────────────────────────────────────────────────────────── +# Hood ID + Zone ID → profile key mapping +# ───────────────────────────────────────────────────────────────────────────── + +_HOOD_ID_MAP: dict[int, str] | None = None +_ZONE_ID_MAP: dict[int, str] = {} # populated lazily + + +def _buildHoodIdMap() -> dict[int, str]: + from toontown.toonbase import ToontownGlobals as TG + m = { + TG.ToontownCentral: 'tt', + TG.DonaldsDock: 'dd', + TG.MinniesMelodyland: 'mm', + TG.DaisyGardens: 'dg', + TG.TheBrrrgh: 'br', + TG.DonaldsDreamland: 'dl', + TG.GoofySpeedway: 'gs', + TG.BossbotHQ: 'bossbot_hq', + TG.SellbotHQ: 'sellbot_hq', + TG.CashbotHQ: 'cashbot_hq', + TG.LawbotHQ: 'lawbot_hq', + } + for attr in ('MyEstate', 'Estate', 'ToonEstate'): + val = getattr(TG, attr, None) + if val is not None: + m[val] = 'estate' + return m + + +def _buildZoneIdMap() -> dict[int, str]: + """Map individual zone IDs to specific sub-profiles.""" + from toontown.toonbase import ToontownGlobals as TG + + def _zr(tg_attr: str) -> int | None: + return getattr(TG, tg_attr, None) + + z: dict[int, str] = {} + + # TT streets + for attr in ('SillyStreet', 'LoopyLane', 'PunchlinePlace'): + v = _zr(attr) + if v: z[v] = 'tt_street' + + # DD streets + for attr in ('BarnacleBoulevard', 'SeaweedStreet', 'LighthouseLane'): + v = _zr(attr) + if v: z[v] = 'dd_street' + + # BR streets + for attr in ('SleetStreet', 'WalrusWay', 'PolarPlace'): + v = _zr(attr) + if v: z[v] = 'br_street' + + # MM streets + for attr in ('AltoAvenue', 'BaritoneBoulevard', 'TenorTerrace'): + v = _zr(attr) + if v: z[v] = 'mm_street' + + # DG streets + for attr in ('ElmStreet', 'MapleStreet', 'OakStreet'): + v = _zr(attr) + if v: z[v] = 'dg_street' + + # DDL streets + for attr in ('LullabyLane', 'PajamaPlace'): + v = _zr(attr) + if v: z[v] = 'dl_street' + + # Interiors + for attr in ('SellbotFactoryInt', 'SellbotLegFactoryInt', + 'LawbotFactoryInt'): + v = _zr(attr) + if v: z[v] = 'factory_int' + + for attr in ('CashbotMintIntA', 'CashbotMintIntB', 'CashbotMintIntC'): + v = _zr(attr) + if v: z[v] = 'mint_int' + + for attr in ('LawbotOfficeInt',): + v = _zr(attr) + if v: z[v] = 'office_int' + + for attr in ('BossbotCountryClubIntA', 'BossbotCountryClubIntB', + 'BossbotCountryClubIntC'): + v = _zr(attr) + if v: z[v] = 'bossbot_cc' + + for attr in ('GolfZone',): + v = _zr(attr) + if v: z[v] = 'golf_course' + + return z + + +def _resolveStyle(style: str, hoodId: int | None, + zoneId: int | None = None) -> str: + global _HOOD_ID_MAP, _ZONE_ID_MAP + + # Zone ID takes priority (most specific). + if zoneId is not None: + if not _ZONE_ID_MAP: + try: + _ZONE_ID_MAP = _buildZoneIdMap() + except Exception: + pass + res = _ZONE_ID_MAP.get(zoneId) + if res and res in _ZONE_PROFILES: + return res + + # Hood ID next. + if hoodId is not None: + if _HOOD_ID_MAP is None: + try: + _HOOD_ID_MAP = _buildHoodIdMap() + except Exception: + _HOOD_ID_MAP = {} + res = _HOOD_ID_MAP.get(hoodId) + if res and res in _ZONE_PROFILES: + return res + + return style if style in _ZONE_PROFILES else 'playground' + + +# ───────────────────────────────────────────────────────────────────────────── +# Module-level shared state +# ───────────────────────────────────────────────────────────────────────────── + +_refCount = 0 +_activeStyle = 'playground' + +# Light rig +_lightRig: NodePath | None = None +_renderLights: list[NodePath] = [] +_keyLightNp: NodePath | None = None +_keyCastsShadows = False +_shadowFocusPos: Vec3 | None = None +_shadowSceneRadius = 180.0 +_shadowMapRes = 1024 +_shadowFilmArea = 180.0 +_shadowFollowDist = 220.0 +_prevShadowCasterState: bool | None = None + +# Fog +_fogNode: Fog | None = None + +# Sky / window +_skyWasDimmed = False +_prevClearColor: tuple | None = None + +# Post-process: CommonFilters bloom + render2dp sun-ray overlay (no scene RTT compositor) +_godRaysCard: NodePath | None = None +_godRaysShader: Shader | None = None +_bloomFilters: Any = None + +# Water reflections +_waterSetups: list[dict] = [] +_waterShader: Shader | None = None + +# Procedural sky +_proceduralSky: Any = None # ProceduralSky instance + +# Day / night +_timeOfDay = 12.0 +_dayNightSpeed = 1.0 / 60.0 +_dayNightAccum = 0.0 +_godRaysTime = 0.0 + +_godRaysTaskName = 'outdoorLightingTask' + +# Street lamp / lantern point lights (active at night) +_lampLights: list[NodePath] = [] # PointLight NodePaths attached to _lightRig +_lampGeomNps: list[NodePath] = [] # Cached lamp post positions scanned from geometry +_lampLightsActive: bool = False + +# Lamp node name keywords – any node whose name contains one of these is treated as a light source +_LAMP_KEYWORDS: tuple = ( + 'lamp', 'lantern', 'streetlight', 'lightpole', 'lamp_post', 'lamppost', + 'prop_lamp', 'prop_lantern', 'light_pole', 'gazebo_lamp', 'post_lamp', + 'street_lamp', 'streetlamp', 'gas_lamp', 'gaslight', 'torch', + 'light_fixture', 'park_lamp', 'lampshade', 'bulb', +) +_LAMP_LIGHT_LIMIT = 60 # max point lights to create (performance guard) + +_SHADOW_CAM_MASK = BitMask32.bit(5) + +_SHADER_DIR = os.path.join(os.path.dirname(__file__), '..', 'shaders') +_LEGACY_VERT = os.path.join(_SHADER_DIR, 'sunrays.vert.glsl') +_LEGACY_FRAG = os.path.join(_SHADER_DIR, 'sunrays.frag.glsl') +_WATER_VERT = os.path.join(_SHADER_DIR, 'water.vert.glsl') +_WATER_FRAG = os.path.join(_SHADER_DIR, 'water.frag.glsl') + +_WATER_PATTERNS = ( + 'water', 'pond', 'ocean', 'sea', 'lake', 'fountain', + 'puddle', 'river', 'stream', 'brook', 'lagoon', 'tide', +) + +_rigRebuildPending: dict | None = None + +# ───────────────────────────────────────────────────────────────────────────── +# Settings helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _wantFx() -> bool: + if _OUTDOOR_SHADER_BISECT_LEVEL < 1: + return False + try: + val = base.settings.getSetting('want-modern-outdoor-lighting', None) + if val is not None: + return bool(val) + except Exception: + pass + return ConfigVariableBool('want-modern-outdoor-lighting', True).value + + +def _settingsBool(key: str, default: bool) -> bool: + try: + return bool(base.settings.getSetting(key, default)) + except Exception: + return default + + +def _settingsFloat(key: str, default: float) -> float: + try: + return float(base.settings.getSetting(key, default)) + except Exception: + return default + + +def _settingsStr(key: str, default: str) -> str: + try: + v = base.settings.getSetting(key, default) + return str(v) if v is not None else default + except Exception: + return default + + +def _wantDayNight() -> bool: + try: + val = base.settings.getSetting('want-day-night-cycle', None) + if val is not None: + return bool(val) + except Exception: + pass + return ConfigVariableBool('want-day-night-cycle', False).value + + +def _wantBloom() -> bool: + return _settingsBool('lighting-bloom-enabled', True) + + +def _wantWater() -> bool: + return _settingsBool('want-water-reflections', True) + + +def _wantDynamicShadows() -> bool: + return _settingsBool('dynamic-shadows', True) + + +def _wantSpecularHighlights() -> bool: + return _settingsBool('lighting-specular-enabled', True) + + +def _wantTonemap() -> bool: + return _settingsBool('lighting-tonemap-enabled', True) + + +def _wantProceduralSky() -> bool: + if _OUTDOOR_SHADER_BISECT_LEVEL < 1: + return False + return _settingsBool('want-procedural-sky', True) + + +def _wantVignette() -> bool: + return _settingsBool('lighting-vignette-enabled', False) + + +def _intensityScale() -> float: + return max(0.1, min(2.0, _settingsFloat('lighting-intensity', 1.0))) + + +def _colorTempBias() -> float: + return max(-1.0, min(1.0, _settingsFloat('lighting-color-temp', 0.0))) + + +def _dayNightSpeedMultiplier() -> float: + return max(0.0, _settingsFloat('day-night-speed', 1.0)) + + +def _vignetteStrength() -> float: + if not _wantVignette(): + return 0.0 + return max(0.0, min(1.0, _settingsFloat('lighting-vignette-strength', 0.25))) + + +def _shadowQuality() -> str: + return _settingsStr('shadow-quality', 'high') # 'off' | 'low' | 'medium' | 'high' + + +def _cloudQuality() -> str: + return _settingsStr('sky-cloud-quality', 'high') # 'off' | 'low' | 'medium' | 'high' + + +def _fogDensityMult() -> float: + return max(0.1, min(3.0, _settingsFloat('fog-density-multiplier', 1.0))) + + +# ───────────────────────────────────────────────────────────────────────────── +# Math / colour helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _clamp(v: float, lo: float, hi: float) -> float: + return max(lo, min(hi, v)) + + +def _lerpF(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + +def _lerpT(a: tuple | None, b: tuple | None, t: float) -> tuple | None: + if a is None and b is None: + return None + if a is None: + a = tuple(0.0 for _ in b) + if b is None: + b = tuple(0.0 for _ in a) + return tuple(_lerpF(float(ai), float(bi), t) for ai, bi in zip(a, b)) + + +def _applyColorTemp(color: tuple) -> tuple: + bias = _colorTempBias() + r, g, b, a = color + if bias > 0: + r = min(1.0, r + bias * 0.08) + b = max(0.0, b - bias * 0.06) + elif bias < 0: + b = min(1.0, b - bias * 0.08) + r = max(0.0, r + bias * 0.06) + return (r, g, b, a) + + +def _scaleColor(color: tuple, scale: float) -> tuple: + return (color[0] * scale, color[1] * scale, color[2] * scale, color[3]) + + +def _scaledShadowRes(spec: dict) -> int: + q = _shadowQuality() + if q == 'off': + return 0 + base_res = int(spec.get('shadowRes', 1024)) + scale = float(spec.get('shadowResScale', 1.0)) + if q == 'low': + scale *= 0.25 + elif q == 'medium': + scale *= 0.50 + res = int(round(base_res * _clamp(scale, 0.25, 2.0))) + res = int(_clamp(res, 512, 4096)) + valid = (512, 1024, 2048, 4096) + return min(valid, key=lambda v: abs(v - res)) + + +# ───────────────────────────────────────────────────────────────────────────── +# Specular + lighting fix helpers +# ───────────────────────────────────────────────────────────────────────────── + +_SPEC_TAG = 'osl_added_material' +_LIGHTON_TAG = 'osl_cleared_lightoff' +_SHADOW_HIDE_TAG = 'osl_hide_from_shadow' + + +def _applyDefaultSpecularMaterial(np: NodePath) -> None: + if not _wantSpecularHighlights() or np is None or np.isEmpty(): + return + try: + if np.hasMaterial(): + return + except Exception: + try: + if np.getMaterial() is not None: + return + except Exception: + pass + try: + if np.getPythonTag(_SPEC_TAG): + return + except Exception: + pass + try: + m = Material() + m.setSpecular(Vec4(0.22, 0.22, 0.22, 1.0)) + m.setShininess(18.0) + np.setMaterial(m, 1) + np.setPythonTag(_SPEC_TAG, True) + except Exception: + pass + + +def _clearDefaultSpecularMaterial(np: NodePath) -> None: + if np is None or np.isEmpty(): + return + try: + if not np.getPythonTag(_SPEC_TAG): + return + except Exception: + return + try: + np.clearMaterial() + except Exception: + pass + try: + np.clearPythonTag(_SPEC_TAG) + except Exception: + pass + + +def _forceLightingOnSubtree(root: NodePath) -> None: + """Clear LightAttrib overrides that disable per-pixel lighting. + + Some geometry has baked vertex colours and explicit "light off" attribs + that prevent the dynamic sun from reaching them. Clearing those lets + the key/fill/rim lights illuminate the full zone. + """ + if root is None or root.isEmpty(): + return + try: + if hasattr(root, 'clearLightOff'): + root.clearLightOff() + try: + root.clearAttrib(LightAttrib.getClassType()) + except Exception: + pass + except Exception: + pass + + +def _hidePlaneLikeCastersFromShadow(root: NodePath) -> None: + """Exclude 2D billboard / card geometry from the shadow CAMERA PASS only. + + 2D foliage planes, building facades, and billboard sprites should NOT + cast sun-blocking shadows (they would create giant flat shadow rectangles). + They still RECEIVE shadows, and they are still lit by the sun. + + We use the dedicated _SHADOW_CAM_MASK so the shadow camera skips them + while the regular render camera sees them normally. + """ + if root is None or root.isEmpty(): + return + + try: + nodes = root.findAllMatches('**/+GeomNode') + for i in range(nodes.getNumPaths()): + np = nodes.getPath(i) + try: + if np.getPythonTag(_SHADOW_HIDE_TAG): + continue + except Exception: + pass + + name = np.getName().lower() + looks_like_card = any(k in name for k in ( + 'card', 'billboard', 'plane', 'sprite', 'foliage', + 'leaf', 'treecard', 'tree', 'bush', 'grass', 'facade', + )) + try: + has_billboard = np.hasEffect(BillboardEffect.getClassType()) + except Exception: + has_billboard = False + try: + has_transparency = np.hasAttrib(TransparencyAttrib.getClassType()) + except Exception: + has_transparency = False + + if looks_like_card or has_billboard or has_transparency: + try: + np.hide(_SHADOW_CAM_MASK) + np.setPythonTag(_SHADOW_HIDE_TAG, True) + except Exception: + pass + except Exception: + pass + + # Second pass: clear light-off overrides so every geom node IS lit. + try: + nodes = root.findAllMatches('**/+GeomNode') + for i in range(nodes.getNumPaths()): + np = nodes.getPath(i) + try: + if np.getPythonTag(_LIGHTON_TAG): + continue + except Exception: + pass + try: + if hasattr(np, 'clearLightOff'): + np.clearLightOff() + try: + np.clearAttrib(LightAttrib.getClassType()) + except Exception: + pass + np.setPythonTag(_LIGHTON_TAG, True) + except Exception: + pass + except Exception: + pass + + +# ───────────────────────────────────────────────────────────────────────────── +# Street lamp / lantern night lights +# ───────────────────────────────────────────────────────────────────────────── + +def _scanForLampNodes(geom) -> list[NodePath]: + """Walk scene geometry and collect lamp/lantern NodePaths for night lights.""" + if geom is None or geom.isEmpty(): + return [] + results: list[NodePath] = [] + try: + nodes = geom.findAllMatches('**/*') + for i in range(nodes.getNumPaths()): + np = nodes.getPath(i) + name = np.getName().lower() + if any(k in name for k in _LAMP_KEYWORDS): + results.append(np) + if len(results) >= _LAMP_LIGHT_LIMIT: + break + except Exception: + pass + return results + + +def _enableLampLights(style: str) -> None: + """Create PointLights at all cached lamp positions (call at dusk/night).""" + global _lampLights, _lampLightsActive + if _lampLightsActive or not _lampGeomNps: + return + if not _lightRig or _lightRig.isEmpty(): + return + + # Zone-specific lamp colour (warm for regular areas, cool-blue for DL / midnight) + nightZones = ('dl', 'dl_street', 'sellbot_hq', 'cashbot_hq', 'lawbot_hq', + 'bossbot_hq', 'cog') + if style in nightZones: + lampColor = Vec4(0.75, 0.82, 1.00, 1.0) # cool blue-white (sodium arc / fantasy) + else: + lampColor = Vec4(1.00, 0.80, 0.48, 1.0) # warm yellow-orange street lamp + + for idx, lampNp in enumerate(_lampGeomNps): + try: + # Get world-space light-head position (try tight bounds top, else Z+offset) + try: + mins, maxs = lampNp.getTightBounds(base.render) + lightPos = Vec3( + (mins[0] + maxs[0]) * 0.5, + (mins[1] + maxs[1]) * 0.5, + maxs[2] - 0.25, + ) + except Exception: + wp = lampNp.getPos(base.render) + lightPos = Vec3(wp.x, wp.y, wp.z + 3.2) + + pl = PointLight(f'outdoorLampLight_{idx}') + pl.setColor(lampColor) + # Attenuation: reaches ~10 units comfortably, falls off by 14 units + pl.setAttenuation(Vec3(0.4, 0.0, 0.032)) + + plNp = _lightRig.attachNewNode(pl) + plNp.setPos(base.render, lightPos) + base.render.setLight(plNp) + _lampLights.append(plNp) + except Exception: + pass + + _lampLightsActive = True + + +def _disableLampLights() -> None: + """Remove all street-lamp PointLights (call at sunrise/day).""" + global _lampLights, _lampLightsActive + for plNp in _lampLights: + try: + base.render.clearLight(plNp) + except Exception: + pass + try: + if not plNp.isEmpty(): + plNp.removeNode() + except Exception: + pass + _lampLights.clear() + _lampLightsActive = False + + +# ───────────────────────────────────────────────────────────────────────────── +# Day / night interpolation +# ───────────────────────────────────────────────────────────────────────────── + +def _evalKeyframes(zone: str, hour: float) -> dict: + frames = _DAY_NIGHT_KEYFRAMES.get(zone) + if not frames: + return {} + hour = hour % 24.0 + valid = [(h, d) for h, d in frames if d is not None] + if not valid: + return {} + if len(valid) == 1: + return dict(valid[0][1]) + + prev_h, prev_d = valid[-1] + next_h, next_d = valid[0] + for i in range(len(valid)): + kh, kd = valid[i] + if kh <= hour: + prev_h, prev_d = kh, kd + nxt_i = (i + 1) % len(valid) + next_h, next_d = valid[nxt_i] + + span = next_h - prev_h + if span <= 0: + span = (24.0 - prev_h) + next_h + t_prog = (hour - prev_h) % 24.0 + else: + t_prog = hour - prev_h + t = _clamp(t_prog / span if span > 0 else 0.0, 0.0, 1.0) + + result: dict = {} + all_keys = set(prev_d.keys()) | set(next_d.keys()) + for key in all_keys: + av = prev_d.get(key) + bv = next_d.get(key) + if isinstance(av, bool) or isinstance(bv, bool): + result[key] = bv if t >= 0.5 else av + elif isinstance(av, (int, float)) and isinstance(bv, (int, float)): + result[key] = _lerpF(float(av), float(bv), t) + elif isinstance(av, (tuple, list)) or isinstance(bv, (tuple, list)): + result[key] = _lerpT( + tuple(av) if av is not None else None, + tuple(bv) if bv is not None else None, + t, + ) + else: + result[key] = bv if t >= 0.5 else av + return result + + +def _getActiveSpec() -> dict: + base_spec = _ZONE_PROFILES.get(_activeStyle, _ZONE_PROFILES['playground']) + if not _wantDayNight() or not base_spec.get('dayNightEnabled', True): + return base_spec + overrides = _evalKeyframes(_activeStyle, _timeOfDay) + if not overrides: + return base_spec + merged = dict(base_spec) + merged.update(overrides) + return merged + + +# ───────────────────────────────────────────────────────────────────────────── +# Sky helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _getHood(): + hood = getattr(getattr(base, 'cr', None), 'playGame', None) + return getattr(hood, 'hood', None) if hood else None + + +def _dimSkyForLights() -> None: + global _skyWasDimmed + hood = _getHood() + if _proceduralSky and _proceduralSky.isActive(): + # Procedural sky manages its own lighting — hide the model sky. + if hood and getattr(hood, 'sky', None) and not hood.sky.isEmpty(): + hood.sky.hide() + _skyWasDimmed = False + return + if hood and getattr(hood, 'sky', None) and not hood.sky.isEmpty(): + hood.sky.setLightOff(1) + _skyWasDimmed = True + + +def _restoreSkyLighting() -> None: + global _skyWasDimmed + if not _skyWasDimmed: + return + hood = _getHood() + if hood and getattr(hood, 'sky', None) and not hood.sky.isEmpty(): + if hasattr(hood.sky, 'clearLightOff'): + hood.sky.clearLightOff() + else: + try: + hood.sky.clearAttrib(LightAttrib.getClassType()) + except Exception: + try: + hood.sky.setLightOff(0) + except Exception: + pass + hood.sky.show() + _skyWasDimmed = False + + +def _tintSky(spec: dict) -> None: + if _proceduralSky and _proceduralSky.isActive(): + return # Sky handled by procedural system + hood = _getHood() + if hood and getattr(hood, 'sky', None) and not hood.sky.isEmpty(): + sc = spec.get('skyScale', (1, 1, 1, 1)) + hood.sky.setColorScale(Vec4(*sc)) + + +def _clearSkyTint() -> None: + hood = _getHood() + if hood and getattr(hood, 'sky', None) and not hood.sky.isEmpty(): + hood.sky.clearColorScale() + hood.sky.show() + + +# ───────────────────────────────────────────────────────────────────────────── +# Window background colour +# ───────────────────────────────────────────────────────────────────────────── + +def _applyBackgroundColor(spec: dict) -> None: + global _prevClearColor + try: + cc = spec.get('clearColor', (0.2, 0.4, 0.6, 1.0)) + if hasattr(base, 'win') and base.win: + if _prevClearColor is None: + _prevClearColor = tuple(base.win.getClearColor()) + base.win.setClearColor(Vec4(*cc)) + except Exception: + pass + + +def _restoreBackgroundColor() -> None: + global _prevClearColor + try: + if _prevClearColor and hasattr(base, 'win') and base.win: + base.win.setClearColor(Vec4(*_prevClearColor)) + except Exception: + pass + _prevClearColor = None + + +# ───────────────────────────────────────────────────────────────────────────── +# Shadow / scene bounds +# ───────────────────────────────────────────────────────────────────────────── + +def _computeFullZoneShadowBounds(geom, defaultArea: float) -> tuple[Vec3, float, float]: + focus = Vec3(0.0, 0.0, 0.0) + half = max(100.0, float(defaultArea)) + if geom is None or geom.isEmpty(): + return (focus, half, half) + try: + bounds = geom.getTightBounds() + if bounds and len(bounds) == 2: + mins, maxs = bounds + center = (mins + maxs) * 0.5 + size = maxs - mins + focus = Vec3(center[0], center[1], center[2]) + half = max(half, max(float(size[0]), float(size[1])) * 0.55) + except Exception: + pass + return (focus, half * 0.8, half) + + +# ───────────────────────────────────────────────────────────────────────────── +# Light rig +# ───────────────────────────────────────────────────────────────────────────── + +def _positionShadowCaster() -> None: + if not (_keyCastsShadows and _keyLightNp and not _keyLightNp.isEmpty() + and _shadowFocusPos is not None): + return + try: + target = Vec3(_shadowFocusPos[0], _shadowFocusPos[1], _shadowFocusPos[2]) + texel = max(0.25, _shadowFilmArea / max(1.0, float(_shadowMapRes))) + # Only snap X and Y to the texel grid to prevent shadow edge swimming. + # Snapping Z too causes the shadow frustum to stutter vertically, which + # manifests as sharp spike-like seam artifacts along geometry silhouettes. + target = Vec3( + round(target[0] / texel) * texel, + round(target[1] / texel) * texel, + target[2], # Z intentionally not snapped – vertical snap = spike artifacts + ) + sunDir = _keyLightNp.getQuat(base.render).getForward() + shadowCamPos = target - sunDir * _shadowFollowDist + _keyLightNp.setPos(base.render, shadowCamPos) + except Exception: + pass + + +def _spawnLightRig(spec: dict, geom=None, + shadowBounds: tuple[Vec3, float] | None = None) -> None: + global _lightRig, _renderLights, _keyLightNp, _shadowFollowDist + global _keyCastsShadows, _shadowFocusPos, _shadowSceneRadius + global _shadowMapRes, _shadowFilmArea, _prevShadowCasterState + + intensity = _intensityScale() + _lightRig = base.render.attachNewNode('outdoorLightRig') + _renderLights = [] + _keyLightNp = None + _keyCastsShadows = False + _shadowFocusPos = None + _shadowSceneRadius = 180.0 + _shadowMapRes = 1024 + _shadowFilmArea = 180.0 + _shadowFollowDist = float(spec.get('shadowFollowDist', 220.0)) + defaultArea = float(spec.get('shadowArea', 180)) + + if shadowBounds is not None: + _shadowFocusPos = Vec3(*shadowBounds[0]) + _shadowSceneRadius = max(60.0, float(shadowBounds[1])) + fullHalf = float(shadowBounds[1]) + else: + focus, radius, fullHalf = _computeFullZoneShadowBounds(geom, defaultArea) + _shadowFocusPos = focus + _shadowSceneRadius = radius + + # ── Ambient ────────────────────────────────────────────────────────────── + floor = float(spec.get('shadowDarknessFloor', 0.18)) + rawAmb = _applyColorTemp(_scaleColor(spec['ambient'], intensity)) + ambColor = ( + max(rawAmb[0], floor * 0.8), + max(rawAmb[1], floor * 0.7), + max(rawAmb[2], floor * 0.9), + rawAmb[3], + ) + amb = AmbientLight('outdoorAmbient') + amb.setColor(Vec4(*ambColor)) + ambNp = _lightRig.attachNewNode(amb) + base.render.setLight(ambNp) + _renderLights.append(ambNp) + + # ── Key / sun ───────────────────────────────────────────────────────────── + keyColor = _applyColorTemp(_scaleColor(spec['key'], intensity)) + key = DirectionalLight('outdoorKey') + key.setColor(Vec4(*keyColor)) + wants_shadow = (spec.get('shadowCaster', False) + and _wantDynamicShadows() + and _shadowQuality() != 'off') + _prevShadowCasterState = wants_shadow + + if wants_shadow: + try: + res = _scaledShadowRes(spec) + key.setShadowCaster(True, res, res) + try: + key.setCameraMask(_SHADOW_CAM_MASK) + except Exception: + pass + _shadowMapRes = res + + # Integer depth bias only. The 3-arg DepthOffsetAttrib.make(off, a, b) is + # NOT (offset, slope, maxBias) — it clamps projected Z to [min_value, max_value] + # in *normalized* depth, both in [0, 1]. Passing 2.5 triggers assertions and + # breaks shadow maps (see logs: depthOffsetAttrib.cxx min_value 0..1 check). + depthOffset = int(spec.get('shadowDepthOffset', 7)) + depthOffset = max(0, min(16, depthOffset)) + key.setInitialState(RenderState.make(DepthOffsetAttrib.make(depthOffset))) + + areaScale = float(spec.get('shadowAreaScale', 1.0)) + area = fullHalf * 2.0 * _clamp(areaScale, 0.75, 2.00) + maxScale = float(spec.get('shadowMaxAreaScale', 3.0)) + area = _clamp(area, defaultArea, + defaultArea * _clamp(maxScale, 1.5, 4.0)) + key.getLens().setFilmSize(area, area) + _shadowFilmArea = float(area) + + nearClip = max(4.0, _shadowFollowDist - (_shadowSceneRadius * 1.35)) + farClip = (_shadowFollowDist + (_shadowSceneRadius * 1.85) + + max(80.0, area * 0.30)) + if farClip <= nearClip + 32.0: + farClip = nearClip + 32.0 + key.getLens().setNearFar(float(nearClip), float(farClip)) + _keyCastsShadows = True + except Exception: + try: + key.setShadowCaster(True, 1024, 1024) + _shadowMapRes = 1024 + key.setInitialState(RenderState.make(DepthOffsetAttrib.make(4))) + fallArea = float(spec.get('shadowArea', 180)) + key.getLens().setFilmSize(fallArea, fallArea) + key.getLens().setNearFar(10, int(_shadowFollowDist * 2.0 + 500)) + _shadowFilmArea = fallArea + _keyCastsShadows = True + except Exception: + pass + + keyNp = _lightRig.attachNewNode(key) + keyNp.setHpr(*spec['keyHpr']) + base.render.setLight(keyNp) + _renderLights.append(keyNp) + _keyLightNp = keyNp + _positionShadowCaster() + + # ── Fill ────────────────────────────────────────────────────────────────── + fillColor = _applyColorTemp(_scaleColor(spec['fill'], intensity * 0.85)) + fill = DirectionalLight('outdoorFill') + fill.setColor(Vec4(*fillColor)) + fillNp = _lightRig.attachNewNode(fill) + fillNp.setHpr(*spec['fillHpr']) + base.render.setLight(fillNp) + _renderLights.append(fillNp) + + # ── Rim ─────────────────────────────────────────────────────────────────── + if spec.get('rim'): + rimColor = _applyColorTemp(_scaleColor(spec['rim'], intensity * 0.70)) + rim = DirectionalLight('outdoorRim') + rim.setColor(Vec4(*rimColor)) + rimNp = _lightRig.attachNewNode(rim) + rimNp.setHpr(*spec['rimHpr']) + base.render.setLight(rimNp) + _renderLights.append(rimNp) + + +def _destroyLightRig() -> None: + global _lightRig, _renderLights, _keyLightNp, _keyCastsShadows + global _shadowFocusPos, _shadowSceneRadius, _shadowMapRes, _shadowFilmArea + global _prevShadowCasterState + _disableLampLights() + for ln in _renderLights: + try: + base.render.clearLight(ln) + except Exception: + pass + _renderLights = [] + _keyLightNp = None + _keyCastsShadows = False + _shadowFocusPos = None + _shadowSceneRadius = 180.0 + _shadowMapRes = 1024 + _shadowFilmArea = 180.0 + _prevShadowCasterState = None + if _lightRig and not _lightRig.isEmpty(): + _lightRig.removeNode() + _lightRig = None + + +def _applyProfileLive(spec: dict) -> None: + if not (_lightRig and not _lightRig.isEmpty()): + return + + intensity = _intensityScale() + floor = float(spec.get('shadowDarknessFloor', 0.18)) + + for np in _renderLights: + node = np.getNode(0) + name = node.getName() + + if isinstance(node, AmbientLight): + raw = _applyColorTemp(_scaleColor( + spec.get('ambient', (0.2, 0.2, 0.3, 1)), intensity)) + c = (max(raw[0], floor * 0.8), max(raw[1], floor * 0.7), + max(raw[2], floor * 0.9), raw[3]) + node.setColor(Vec4(*c)) + + elif name == 'outdoorKey': + c = _applyColorTemp(_scaleColor( + spec.get('key', (1, 1, 1, 1)), intensity)) + node.setColor(Vec4(*c)) + np.setHpr(*spec.get('keyHpr', (0, -45, 0))) + + elif name == 'outdoorFill': + c = _applyColorTemp(_scaleColor( + spec.get('fill', (0.5, 0.5, 0.5, 1)), intensity * 0.85)) + node.setColor(Vec4(*c)) + + elif name == 'outdoorRim': + rim = spec.get('rim') + if rim: + c = _applyColorTemp(_scaleColor(rim, intensity * 0.70)) + node.setColor(Vec4(*c)) + + # Fog live update + if _fogNode: + fc = spec.get('fogColor', (0.5, 0.6, 0.8, 1)) + exp = spec.get('fogExponent') + dm = _fogDensityMult() + _fogNode.setColor(Vec4(*fc)) + if exp is not None: + _fogNode.setExpDensity(exp * dm) + elif spec.get('fogFar'): + near_v = spec.get('fogNear', 80.0) + far_v = spec.get('fogFar', 450.0) + adjusted_near = near_v / dm + adjusted_far = far_v / dm + _fogNode.setLinearRange(adjusted_near, adjusted_far) + + _tintSky(spec) + _applyBackgroundColor(spec) + + # God rays overlay (render2dp) + if _godRaysCard and not _godRaysCard.isEmpty(): + rawI = spec.get('rayIntensity', 0.0) + eff = rawI * _intensityScale() + if _settingsBool('lighting-god-rays', True) and eff > 0.001: + _godRaysCard.setShaderInput('rayIntensity', eff) + _godRaysCard.setShaderInput('rayColor', + Vec4(*spec.get('rayColor', (1, 1, 1, 1)))) + _godRaysCard.show() + else: + _godRaysCard.hide() + + # Shadow-caster state transition + global _prevShadowCasterState + wants = (spec.get('shadowCaster', False) + and _wantDynamicShadows() + and _shadowQuality() != 'off') + if wants != _prevShadowCasterState: + _scheduleRigRebuild(spec) + + +def _scheduleRigRebuild(spec: dict) -> None: + global _rigRebuildPending + _rigRebuildPending = spec + + +def _flushRigRebuild() -> None: + global _rigRebuildPending + if _rigRebuildPending is None: + return + spec = _rigRebuildPending + _rigRebuildPending = None + savedBounds = None + if _shadowFocusPos is not None: + savedBounds = (Vec3(_shadowFocusPos), float(_shadowSceneRadius)) + _destroyLightRig() + _spawnLightRig(spec, shadowBounds=savedBounds) + + +# ───────────────────────────────────────────────────────────────────────────── +# Atmospheric fog +# ───────────────────────────────────────────────────────────────────────────── + +def _applyFog(spec: dict) -> None: + global _fogNode + if not _settingsBool('lighting-fog-enabled', True): + return + fogColor = spec.get('fogColor') + if not fogColor: + return + dm = _fogDensityMult() + _fogNode = Fog('outdoorFog') + _fogNode.setColor(Vec4(*fogColor)) + exponent = spec.get('fogExponent') + if exponent is not None: + _fogNode.setExpDensity(exponent * dm) + else: + near_v = spec.get('fogNear', 80.0) + far_v = spec.get('fogFar', 450.0) + _fogNode.setLinearRange(near_v / dm, far_v / dm) + base.render.setFog(_fogNode) + + +def _clearFog() -> None: + global _fogNode + try: + base.render.clearFog() + except Exception: + pass + _fogNode = None + + +# ───────────────────────────────────────────────────────────────────────────── +# Post-process (bloom + 2D sun-ray overlay — no custom scene→texture compositor) +# ───────────────────────────────────────────────────────────────────────────── + +def _maintainOutdoorLightingViewport() -> None: + """Keep the main window's DisplayRegions at full size and pixel_zoom=1. + + Two separate issues produce the same “3D in the bottom-left quarter” look: + (1) per-DisplayRegion **pixel_zoom** > 1, or (2) normalized **dimensions** + smaller than the full window on the compositor / 3D region. Stock + FilterManager does not always keep those in sync on every GL driver. + + When outdoor FX is active we therefore reset **every** main-window + DisplayRegion to (0,1)×(0,1) and pixel_zoom 1 each frame. That can break + rare sub-rectangle DR tricks (e.g. picture-in-picture) while the rig runs. + + Each operation is individually guarded so that a threading assertion on + win.setPixelZoom (observed on the custom Panda3D build) cannot abort the + entire repair and leave the DR dimensions uncorrected. + """ + win = getattr(base, 'win', None) + if not win: + return + + # Window-level pixel zoom — may raise assertion on some builds; keep isolated. + try: + win.setPixelZoom(1) + except Exception: + pass + + # Per-DR dimensions and pixel zoom. + try: + n = win.getNumDisplayRegions() + except Exception: + n = 0 + for i in range(n): + try: + dr = win.getDisplayRegion(i) + if not dr: + continue + dr.setDimensions(0.0, 1.0, 0.0, 1.0) + try: + if dr.supportsPixelZoom(): + dr.setPixelZoom(1) + except Exception: + pass + except Exception: + pass + + # RTT buffer DisplayRegions (scene capture buffers, bloom passes, etc.) + _fixPostProcessRttViewports() + + +def _setupCinematicPost(spec: dict) -> None: + """Bloom plus additive sun shafts — no full-window scene RTT compositor.""" + _setupBloom(spec) + _createGodRaysOverlay(spec) + + +def _setupPostProcess(spec: dict) -> None: + _destroyPostProcess() + + if not _wantFx(): + return + + mode = 'full' + if _OUTDOOR_SHADER_BISECT_LEVEL == 2: + mode = _LEVEL2_POST_SINGLE + if mode not in ('full', 'bloom', 'godrays', 'stack'): + mode = 'full' + + if mode == 'godrays': + _createGodRaysOverlay(spec) + return + if mode == 'bloom': + _setupBloom(spec) + return + # 'full' and 'stack': bloom + god rays + _setupCinematicPost(spec) + + +def _destroyPostProcess() -> None: + _destroyBloom() + _destroyGodRaysOverlay() + + +# ───────────────────────────────────────────────────────────────────────────── +# Bloom (CommonFilters) +# ───────────────────────────────────────────────────────────────────────────── + +def _setupBloom(spec: dict) -> None: + global _bloomFilters + if not _wantBloom(): + return + intensity = float(spec.get('bloomIntensity', 0.0)) + if intensity <= 0.001: + return + try: + from direct.filter.CommonFilters import CommonFilters + _bloomFilters = CommonFilters(base.win, base.cam) + threshold = float(spec.get('bloomThreshold', 0.65)) + size = 'large' if intensity >= 0.55 else ('medium' if intensity >= 0.25 else 'small') + _bloomFilters.setBloom( + size=size, + intensity=intensity, + mintrigger=threshold, + maxtrigger=min(1.0, threshold + 0.30), + desat=-0.4, + ) + # CommonFilters uses the base FilterManager.renderSceneInto which calls + # buffer.makeDisplayRegion() without explicit bounds. On some GL drivers + # (including the custom OpenToontown build) that defaults to a bottom-left + # sub-rectangle, causing the scene to render into only part of the colour + # texture — the root cause of the "3D view in the bottom-left" bug. + # Force full-window bounds on every offscreen DR immediately after setup. + _fixPostProcessRttViewports() + except Exception: + _bloomFilters = None + + +def _destroyBloom() -> None: + global _bloomFilters + if _bloomFilters is not None: + try: + _bloomFilters.cleanup() + except Exception: + pass + _bloomFilters = None + + +def _updateBloomLive(spec: dict) -> None: + if _bloomFilters is None: + return + try: + intensity = float(spec.get('bloomIntensity', 0.0)) * _intensityScale() + if intensity <= 0.001: + _bloomFilters.delBloom() + return + threshold = float(spec.get('bloomThreshold', 0.65)) + size = 'large' if intensity >= 0.55 else ('medium' if intensity >= 0.25 else 'small') + _bloomFilters.setBloom( + size=size, intensity=intensity, + mintrigger=threshold, maxtrigger=min(1.0, threshold + 0.30), + desat=-0.4, + ) + except Exception: + pass + + +# ───────────────────────────────────────────────────────────────────────────── +# Sun-ray overlay (render2dp) +# ───────────────────────────────────────────────────────────────────────────── + +def _loadGodRaysShader() -> Shader | None: + global _godRaysShader + if _godRaysShader is not None: + return _godRaysShader + try: + vp_os = os.path.normpath(_LEGACY_VERT) + fp_os = os.path.normpath(_LEGACY_FRAG) + if not (os.path.isfile(vp_os) and os.path.isfile(fp_os)): + return None + vp = Filename.fromOsSpecific(vp_os) + fp = Filename.fromOsSpecific(fp_os) + try: + vp.makeTrueCase() + fp.makeTrueCase() + except Exception: + pass + _godRaysShader = Shader.load(Shader.SL_GLSL, vp, fp) + return _godRaysShader + except Exception: + return None + + +def _createGodRaysOverlay(spec: dict) -> None: + global _godRaysCard + if not _settingsBool('lighting-god-rays', True): + return + rawI = spec.get('rayIntensity', 0.0) + if rawI <= 0.0: + return + shader = _loadGodRaysShader() + if shader is None: + return + try: + cm = CardMaker('outdoorGodRays') + cm.setFrameFullscreenQuad() + card = base.render2dp.attachNewNode(cm.generate()) + card.setDepthTest(False) + card.setDepthWrite(False) + card.setTransparency(TransparencyAttrib.MAlpha) + card.setAttrib(ColorBlendAttrib.make( + ColorBlendAttrib.MAdd, + ColorBlendAttrib.OIncomingAlpha, + ColorBlendAttrib.OOne, + )) + card.setShader(shader) + + su, sv = spec.get('sunUV', (0.5, 0.75)) + rc = spec.get('rayColor', (1.0, 1.0, 1.0, 1.0)) + eff = rawI * _intensityScale() + card.setShaderInput('sunPos', (su, sv)) + card.setShaderInput('rayColor', Vec4(*rc)) + card.setShaderInput('rayIntensity', eff) + card.setShaderInput('time', 0.0) + try: + props = base.win.getProperties() + ar = props.getXSize() / max(1, props.getYSize()) + except Exception: + ar = 1.333 + card.setShaderInput('aspectRatio', ar) + _godRaysCard = card + except Exception: + _godRaysCard = None + + +def _destroyGodRaysOverlay() -> None: + global _godRaysCard + if _godRaysCard and not _godRaysCard.isEmpty(): + _godRaysCard.removeNode() + _godRaysCard = None + + +# ───────────────────────────────────────────────────────────────────────────── +# Procedural sky +# ───────────────────────────────────────────────────────────────────────────── + +def _setupProceduralSky(spec: dict) -> None: + global _proceduralSky + if not _wantProceduralSky(): + return + try: + from toontown.hood.ProceduralSky import ProceduralSky + _proceduralSky = ProceduralSky() + # Parent to the lens NodePath (base.cam) so the dome shares the same + # transform as the camera FilterManager renders with; base.camera can sit + # slightly off the lens on third-person rigs. + _skyParent = base.cam if (getattr(base, 'cam', None) and not base.cam.isEmpty()) else base.camera + _proceduralSky.attach(_skyParent, style=_activeStyle) + _proceduralSky.update(spec, _timeOfDay) + hood = _getHood() + if hood and getattr(hood, 'sky', None) and not hood.sky.isEmpty(): + # If the procedural sky failed to attach (shader missing, etc), + # keep the legacy model sky visible as a fallback. + if _proceduralSky and _proceduralSky.isActive(): + hood.sky.hide() + else: + hood.sky.show() + except Exception: + _proceduralSky = None + + +def _destroyProceduralSky() -> None: + global _proceduralSky + if _proceduralSky is not None: + try: + _proceduralSky.detach() + except Exception: + pass + _proceduralSky = None + + +# ───────────────────────────────────────────────────────────────────────────── +# Water reflection system (unchanged from previous version) +# ───────────────────────────────────────────────────────────────────────────── + +def _loadWaterShader() -> Shader | None: + global _waterShader + if _waterShader is not None: + return _waterShader + try: + vp_os = os.path.normpath(_WATER_VERT) + fp_os = os.path.normpath(_WATER_FRAG) + if not (os.path.isfile(vp_os) and os.path.isfile(fp_os)): + return None + vp = Filename.fromOsSpecific(vp_os) + fp = Filename.fromOsSpecific(fp_os) + try: + vp.makeTrueCase() + fp.makeTrueCase() + except Exception: + pass + _waterShader = Shader.load(Shader.SL_GLSL, vp, fp) + return _waterShader + except Exception: + return None + + +def _isWaterNode(name: str) -> bool: + nl = name.lower() + return any(p in nl for p in _WATER_PATTERNS) + + +def _findWaterNodes(geom) -> list[NodePath]: + if geom is None or geom.isEmpty(): + return [] + results: list[NodePath] = [] + try: + all_nodes = geom.findAllMatches('**/*') + for i in range(all_nodes.getNumPaths()): + np = all_nodes.getPath(i) + if _isWaterNode(np.getName()): + results.append(np) + except Exception: + pass + return results + + +def _setupWaterNode(waterNp: NodePath, spec: dict) -> dict | None: + if not _wantWater(): + return None + shader = _loadWaterShader() + if shader is None: + wc = spec.get('waterColor', (0.22, 0.38, 0.55, 0.85)) + try: + waterNp.setColorScale(Vec4(*wc)) + except Exception: + pass + return None + + quality = spec.get('waterReflQuality', 'medium') + bufW, bufH = {'high': (1024, 1024), 'low': (256, 256)}.get(quality, (512, 512)) + + try: + buf = base.win.makeTextureBuffer('waterRefl', bufW, bufH) + buf.setClearColor(Vec4(0.1, 0.2, 0.3, 1.0)) + reflTex = buf.getTexture() + reflCamNp = base.makeCamera(buf) + reflCamNp.reparentTo(base.render) + + waterHeight = waterNp.getZ(base.render) + taskName = f'waterReflTask_{id(waterNp)}' + + def _syncReflCam(task, rnp=reflCamNp, wh=waterHeight): + if rnp.isEmpty() or base.camera.isEmpty(): + return task.done + cp = base.camera.getPos(base.render) + ch = base.camera.getHpr(base.render) + mz = 2.0 * wh - cp.z + rnp.setPos(base.render, cp.x, cp.y, mz) + rnp.setHpr(base.render, ch.x, -ch.y, ch.z) + try: + rnp.node().getLens().setNearFar( + base.camNode.getLens().getNear(), + base.camNode.getLens().getFar(), + ) + except Exception: + pass + return task.cont + + taskMgr.add(_syncReflCam, taskName, sort=44) + + wc = spec.get('waterColor', (0.22, 0.38, 0.55, 0.85)) + sdir = Vec3(0, -1, -1) + if _keyLightNp and not _keyLightNp.isEmpty(): + try: + sdir = _keyLightNp.getQuat(base.render).getForward() + sdir.normalize() + except Exception: + pass + + waterNp.setShaderAuto(False) + waterNp.setShader(shader) + waterNp.setShaderInput('osl_ReflectionTex', reflTex) + waterNp.setShaderInput('osl_WaterColor', Vec4(*wc)) + waterNp.setShaderInput('osl_SunColor', Vec4(1.0, 0.95, 0.80, 1.0)) + waterNp.setShaderInput('osl_SunDir', sdir) + waterNp.setShaderInput('osl_CameraPos', base.camera.getPos(base.render)) + waterNp.setShaderInput('osl_Time', 0.0) + waterNp.setShaderInput('osl_WaveScale', 1.4) + waterNp.setShaderInput('osl_WaveSpeed', 0.8) + waterNp.setShaderInput('osl_FresnelPower', 3.5) + waterNp.setShaderInput('osl_Roughness', 0.35) + waterNp.setShaderInput('osl_ReflectionStrength', 0.72) + waterNp.setTransparency(TransparencyAttrib.MAlpha) + + return { + 'np': waterNp, + 'buffer': buf, + 'camera': reflCamNp, + 'taskName': taskName, + } + except Exception: + return None + + +def _setupAllWater(geom, spec: dict) -> None: + global _waterSetups + if not spec.get('hasWater', False) or not _wantWater(): + return + for np in _findWaterNodes(geom): + setup = _setupWaterNode(np, spec) + if setup: + _waterSetups.append(setup) + + +def _cleanupAllWater() -> None: + global _waterSetups, _waterShader + for ws in _waterSetups: + try: + taskMgr.remove(ws['taskName']) + except Exception: + pass + try: + cam = ws.get('camera') + if cam and not cam.isEmpty(): + cam.removeNode() + except Exception: + pass + try: + buf = ws.get('buffer') + if buf: + base.graphicsEngine.removeWindow(buf) + except Exception: + pass + try: + np = ws.get('np') + if np and not np.isEmpty(): + np.clearShader() + np.clearColorScale() + except Exception: + pass + _waterSetups = [] + _waterShader = None + + +def _tickWaterUniforms(dt: float) -> None: + if not _waterSetups: + return + camPos = None + sunDir = Vec3(0, -1, -1) + try: + camPos = base.camera.getPos(base.render) + except Exception: + pass + if _keyLightNp and not _keyLightNp.isEmpty(): + try: + sunDir = _keyLightNp.getQuat(base.render).getForward() + sunDir.normalize() + except Exception: + pass + for ws in _waterSetups: + np = ws.get('np') + if not np or np.isEmpty(): + continue + try: + np.setShaderInput('osl_Time', _godRaysTime) + if camPos: + np.setShaderInput('osl_CameraPos', camPos) + np.setShaderInput('osl_SunDir', sunDir) + except Exception: + pass + + +# ───────────────────────────────────────────────────────────────────────────── +# Per-frame update task +# ───────────────────────────────────────────────────────────────────────────── + +def _lightingUpdateTask(task): + global _godRaysTime, _timeOfDay, _dayNightAccum + + dt = globalClock.getDt() + _godRaysTime += dt + + if _refCount > 0: + _maintainOutdoorLightingViewport() + + # ── Shadow camera positioning ───────────────────────────────────────────── + _positionShadowCaster() + + # ── Water uniforms ──────────────────────────────────────────────────────── + _tickWaterUniforms(dt) + + # ── Procedural sky update ───────────────────────────────────────────────── + if _proceduralSky and _proceduralSky.isActive(): + spec = _getActiveSpec() + _proceduralSky.update(spec, _timeOfDay) + + # ── Day / night cycle ───────────────────────────────────────────────────── + if _wantDayNight() and _refCount > 0: + spec = _ZONE_PROFILES.get(_activeStyle, _ZONE_PROFILES['playground']) + if spec.get('dayNightEnabled', True): + _dayNightAccum += dt + if _dayNightAccum >= 0.25: # 4 Hz update rate + gameHours = (_dayNightAccum + * _dayNightSpeed + * _dayNightSpeedMultiplier()) + _timeOfDay = (_timeOfDay + gameHours) % 24.0 + _dayNightAccum = 0.0 + cur_spec = _getActiveSpec() + _applyProfileLive(cur_spec) + _updateBloomLive(cur_spec) + + # ── Street lamp lights: on at night, off during day ─────────────────────── + if _lampGeomNps and _lightRig and not _lightRig.isEmpty(): + try: + cur_spec = _getActiveSpec() + kHpr = cur_spec.get('keyHpr', (135, -42, 0)) + # Derive sun elevation: the key-light pitch angle p gives us: + # lightFwd.z = -sin(p), sunDirWorld.z = sin(p) + # p=-42 → sin(-42°)=-0.669 → sunDirWorld.z = -0.669 → below horizon + # p=+42 → sin(+42°)=+0.669 → sunDirWorld.z = +0.669 → above horizon + sunDirZ = math.sin(math.radians(kHpr[1])) + if sunDirZ > 0.10: # sun more than ~6° below horizon → enable lamps + _enableLampLights(_activeStyle) + else: + _disableLampLights() + except Exception: + pass + + # ── Sun-ray overlay shimmer ─────────────────────────────────────────────── + if _godRaysCard and not _godRaysCard.isEmpty(): + _godRaysCard.setShaderInput('time', _godRaysTime) + + # ── Deferred rig rebuild ────────────────────────────────────────────────── + _flushRigRebuild() + + return task.cont + + +# ───────────────────────────────────────────────────────────────────────────── +# Public API +# ───────────────────────────────────────────────────────────────────────────── + +def begin(geom, style: str = 'playground', + hoodId: int | None = None, + zoneId: int | None = None) -> None: + """Activate the outdoor lighting rig for the given geometry. + + Parameters + ---------- + geom: + Scene-geometry NodePath; receives per-pixel auto-shading. + style: + Fallback profile key (``'playground'``, ``'estate'``, ``'cog'``, etc.). + hoodId: + ToontownGlobals hood constant – overrides *style* for zone lookup. + zoneId: + Specific zone constant (e.g. SillyStreet) – used for street-level + profiles. Takes priority over *hoodId*. + """ + global _refCount, _activeStyle + if not _wantFx(): + return + + resolvedStyle = _resolveStyle(style, hoodId, zoneId) + spec = _ZONE_PROFILES[resolvedStyle] + + if _refCount == 0: + _activeStyle = resolvedStyle + cur_spec = _getActiveSpec() if spec.get('dayNightEnabled', True) else spec + _spawnLightRig(cur_spec, geom) + _applyFog(cur_spec) + _tintSky(cur_spec) + _applyBackgroundColor(cur_spec) + _setupProceduralSky(cur_spec) + if _OUTDOOR_SHADER_BISECT_LEVEL >= 2: + _setupPostProcess(cur_spec) + if _OUTDOOR_SHADER_BISECT_LEVEL >= 3: + _setupAllWater(geom, cur_spec) + _dimSkyForLights() + # Full-window + per-DisplayRegion pixel_zoom (see _maintainOutdoorLightingViewport). + _maintainOutdoorLightingViewport() + # Run before most gameplay (low sort = earlier): keep viewports patched pre-cull. + taskMgr.add(_lightingUpdateTask, _godRaysTaskName, sort=-60) + + _refCount += 1 + + if geom is not None and not geom.isEmpty(): + geom.setShaderAuto() + _forceLightingOnSubtree(geom) + _hidePlaneLikeCastersFromShadow(geom) + _applyDefaultSpecularMaterial(geom) + # Scan for lamp/lantern nodes to use as night-light emitters. + # Only scan on first begin() (refCount was 0 before increment). + if _refCount == 1: + global _lampGeomNps + _lampGeomNps = _scanForLampNodes(geom) + + lav = getattr(base, 'localAvatar', None) + if lav is not None and not lav.isEmpty(): + lav.setShaderAuto() + + +def end(geom=None) -> None: + """Deactivate the outdoor lighting rig.""" + global _refCount + if not _wantFx(): + return + + if geom is not None and not geom.isEmpty(): + geom.clearShader() + _clearDefaultSpecularMaterial(geom) + lav = getattr(base, 'localAvatar', None) + if lav is not None and not lav.isEmpty(): + lav.clearShader() + + if _refCount > 0: + _refCount -= 1 + + if _refCount == 0: + taskMgr.remove(_godRaysTaskName) + _destroyPostProcess() + _cleanupAllWater() + _clearFog() + _clearSkyTint() + _restoreBackgroundColor() + _destroyLightRig() + _restoreSkyLighting() + _destroyProceduralSky() + global _lampGeomNps + _lampGeomNps = [] + + +def shadeExtraSubtree(np) -> None: + """Enable auto-shading on an extra outdoor NodePath.""" + if not _wantFx() or np is None or np.isEmpty(): + return + np.setShaderAuto() + _forceLightingOnSubtree(np) + _hidePlaneLikeCastersFromShadow(np) + _applyDefaultSpecularMaterial(np) + + +def clearExtraSubtree(np) -> None: + """Remove auto-shading from an extra outdoor NodePath.""" + if not _wantFx() or np is None or np.isEmpty(): + return + np.clearShader() + _clearDefaultSpecularMaterial(np) + + +def refreshSettings() -> None: + """Hot-reload all active lighting parameters from current user settings. + + Called by the Lighting settings tab whenever the player changes a slider + or toggle. All systems are updated in-place where possible. + """ + if _refCount == 0: + return + + spec = _getActiveSpec() + + # Rebuild light rig (picks up resolution / shadow quality changes). + savedBounds = None + if _shadowFocusPos is not None: + savedBounds = (Vec3(_shadowFocusPos), float(_shadowSceneRadius)) + _destroyLightRig() + _spawnLightRig(spec, shadowBounds=savedBounds) + + _clearFog() + _applyFog(spec) + _tintSky(spec) + _applyBackgroundColor(spec) + + # Procedural sky toggle. + if _wantProceduralSky(): + if _proceduralSky is None: + _setupProceduralSky(spec) + elif _proceduralSky is not None: + _proceduralSky.setStyle(_activeStyle) + _proceduralSky.update(spec, _timeOfDay) + else: + _destroyProceduralSky() + _tintSky(spec) + + # Bloom + sun-ray overlay + _destroyPostProcess() + if _OUTDOOR_SHADER_BISECT_LEVEL >= 2: + _setupPostProcess(spec) + + +def setTimeOfDay(hours: float) -> None: + """Set the current in-game time of day (0–24).""" + global _timeOfDay + _timeOfDay = float(hours) % 24.0 + if _refCount > 0: + cur = _getActiveSpec() + savedBounds = None + if _shadowFocusPos is not None: + savedBounds = (Vec3(_shadowFocusPos), float(_shadowSceneRadius)) + _destroyLightRig() + _spawnLightRig(cur, shadowBounds=savedBounds) + _clearFog() + _applyFog(cur) + _tintSky(cur) + _applyBackgroundColor(cur) + _updateBloomLive(cur) + if _proceduralSky: + _proceduralSky.update(cur, _timeOfDay) + + +def setupWaterReflection(waterNodePath, profile: dict | None = None) -> None: + """Manually set up planar reflections on a specific water NodePath.""" + if not _wantFx() or waterNodePath is None or waterNodePath.isEmpty(): + return + base_spec = _ZONE_PROFILES.get(_activeStyle, _ZONE_PROFILES['playground']) + merged = dict(base_spec) + if profile: + merged.update(profile) + setup = _setupWaterNode(waterNodePath, merged) + if setup: + _waterSetups.append(setup) + + +def setWaterReflectionQuality(level: str) -> None: + """Adjust reflection quality for all active water surfaces.""" + if level == 'off': + _cleanupAllWater() + return + spec = _getActiveSpec() + spec_copy = dict(spec) + spec_copy['waterReflQuality'] = level + for ws in list(_waterSetups): + np = ws.get('np') + try: + taskMgr.remove(ws['taskName']) + except Exception: + pass + try: + cam = ws.get('camera') + if cam and not cam.isEmpty(): + cam.removeNode() + except Exception: + pass + try: + buf = ws.get('buffer') + if buf: + base.graphicsEngine.removeWindow(buf) + except Exception: + pass + if np and not np.isEmpty(): + new_setup = _setupWaterNode(np, spec_copy) + if new_setup: + _waterSetups.append(new_setup) + _waterSetups[:] = [ws for ws in _waterSetups + if ws.get('taskName', '').startswith('waterReflTask_')] diff --git a/toontown/hood/Place.py b/toontown/hood/Place.py index fa346a2..97517fc 100644 --- a/toontown/hood/Place.py +++ b/toontown/hood/Place.py @@ -774,6 +774,8 @@ class Place(StateData, FriendsListManager): base.localAvatar.stopPosHprBroadcast() def requestTeleport(self, hoodId, zoneId, shardId, avId): + if avId is None: + avId = -1 if avId > 0: teleportNotify.debug('requestTeleport%s' % ((hoodId, zoneId, diff --git a/toontown/hood/ProceduralSky.py b/toontown/hood/ProceduralSky.py new file mode 100644 index 0000000..b944607 --- /dev/null +++ b/toontown/hood/ProceduralSky.py @@ -0,0 +1,384 @@ +"""Procedural code-generated sky system for Toontown. + +Replaces the hood.sky model-based sky with a GLSL-driven atmospheric sky dome +that includes: + • Rayleigh + Mie scattering sky gradient + • Wide sunset/sunrise horizon corona + • Visible SUN DISC with corona, limb darkening, blinding glare + • Volumetric-looking FBM cumulus clouds with domain warping + • Twinkling star field with spectral colour variation + • Moon disc with surface detail and corona (all night zones) + • Full day/night cycle integration + +Usage (from SkyUtil / OutdoorLighting) +────────────────────────────────────── + from toontown.hood.ProceduralSky import ProceduralSky + + sky = ProceduralSky() + # Prefer the lens NodePath (e.g. base.cam) so the dome matches the view matrix + # used for rendering; base.camera is fine when it coincides with the lens. + sky.attach(base.cam) + sky.update(spec, timeOfDay) # call each frame (or at least on spec changes) + sky.detach() # cleanup on zone exit + +The ProceduralSky.update() signature accepts the same 'spec' dict as +OutdoorLighting zone profiles, so integration is zero-cost. + +Per-zone sky parameters (added to _ZONE_PROFILES by OutdoorLighting): + cloudCoverage – 0.0–1.0 + cloudSpeed – relative speed multiplier + cloudSharpness – 0.0 (soft) – 1.0 (sharp) + turbidity – 1.0–8.0 (Mie haze) + starBrightness – 0.0–1.0 + moonEnabled – bool (all night zones, not just DL) + moonDir – (h,p,r) HPR for moon direction (matches keyHpr format) + skyExposure – scalar (passed to post-process; default 1.0) + sunBlindStrength– 0.0–1.0 blinding glare when looking at sun (default 0.85) +""" + +from __future__ import annotations + +import math +import os + +from panda3d.core import ( + Geom, + GeomNode, + GeomTriangles, + GeomVertexData, + GeomVertexFormat, + GeomVertexWriter, + Filename, + NodePath, + Shader, + Vec3, + Vec4, +) + +from direct.showbase.ShowBaseGlobal import globalClock +from direct.task.TaskManagerGlobal import taskMgr + +from toontown.toonbase.ToonBaseGlobal import base + +_SHADER_DIR = os.path.join(os.path.dirname(__file__), '..', 'shaders') +_SKY_VERT = os.path.join(_SHADER_DIR, 'sky.vert.glsl') +_SKY_FRAG = os.path.join(_SHADER_DIR, 'sky.frag.glsl') + +_SKY_RADIUS = 950.0 # units – large enough to contain all Toontown geometry +_CLOUD_BASE_SPEED = 0.60 # base cloud animation speed (modified per zone) + +_sky_shader: Shader | None = None + + +def _loadSkyShader() -> Shader | None: + global _sky_shader + if _sky_shader is not None: + return _sky_shader + try: + from toontown.hood import OutdoorLighting as osl + if getattr(osl, '_OUTDOOR_SHADER_BISECT_LEVEL', 0) < 1: + return None + except Exception: + pass + try: + vp_os = os.path.normpath(_SKY_VERT) + fp_os = os.path.normpath(_SKY_FRAG) + if not (os.path.isfile(vp_os) and os.path.isfile(fp_os)): + return None + # On Windows, Panda3D's shader loader expects Panda-style paths (eg + # `/c/Users/...`) rather than raw OS paths with backslashes. + vp = Filename.fromOsSpecific(vp_os) + fp = Filename.fromOsSpecific(fp_os) + try: + vp.makeTrueCase() + fp.makeTrueCase() + except Exception: + pass + _sky_shader = Shader.load(Shader.SL_GLSL, vp, fp) + return _sky_shader + except Exception: + return None + + +def _makeSkySphereMesh(radius: float = _SKY_RADIUS, + latSegs: int = 18, + lonSegs: int = 36) -> NodePath: + """Build an inward-facing UV sphere NodePath. + + The sphere is centred at the origin in model space. When attached to the + camera NodePath the camera is always at the sphere centre, so every vertex + position is a ray direction from the camera. + """ + vfmt = GeomVertexFormat.getV3() + vdata = GeomVertexData('skyDomeMesh', vfmt, Geom.UHStatic) + vdata.setNumRows((latSegs + 1) * (lonSegs + 1)) + vwrite = GeomVertexWriter(vdata, 'vertex') + + for lat in range(latSegs + 1): + phi = math.pi * lat / latSegs # 0 → π (north pole → south pole) + sp = math.sin(phi) + cp = math.cos(phi) + for lon in range(lonSegs + 1): + theta = 2.0 * math.pi * lon / lonSegs + x = radius * sp * math.cos(theta) + y = radius * sp * math.sin(theta) + z = radius * cp + vwrite.addData3(x, y, z) + + tris = GeomTriangles(Geom.UHStatic) + stride = lonSegs + 1 + for lat in range(latSegs): + for lon in range(lonSegs): + v0 = lat * stride + lon + v1 = lat * stride + lon + 1 + v2 = (lat + 1) * stride + lon + v3 = (lat + 1) * stride + lon + 1 + # Inward-facing: flip winding compared to outward sphere. + tris.addVertices(v0, v2, v1) + tris.addVertices(v1, v2, v3) + tris.closePrimitive() + + geom = Geom(vdata) + geom.addPrimitive(tris) + gnode = GeomNode('skyDomeGeom') + gnode.addGeom(geom) + return NodePath(gnode) + + +def _dir_world_to_cam(world_dir: Vec3) -> Vec3: + """Map a world-space *direction* into the active camera's local space. + + The sky dome is parented to ``base.cam``, so ``vDir`` in the GLSL fragment + shader is in **camera space**. ``sunDir`` / ``moonDir`` must match that + space or dot products (sun disc, clouds, Mie) are wrong and the sky looks + like a flat clear colour with no sun or clouds. + """ + try: + cam = getattr(base, 'cam', None) + rnp = getattr(base, 'render', None) + if cam is None or rnp is None or cam.isEmpty() or rnp.isEmpty(): + return Vec3(world_dir) + v = cam.getRelativeVector(rnp, Vec3(world_dir)) + ln = v.length() + if ln > 1.0e-7: + v /= ln + return v + except Exception: + return Vec3(world_dir) + + +def _hprToDir(h_deg: float, p_deg: float) -> Vec3: + """Convert a Panda3D HPR heading/pitch to a world-space direction vector. + + The direction returned is the *forward* vector that a node with (H, P, 0) + orientation points toward. For the sun key light this is the direction the + light shines (scene ← sun); for sun position in the sky pass the negated + result. + """ + h = math.radians(h_deg) + p = math.radians(p_deg) + # Panda3D right-hand Y-forward Z-up: + # H rotates around Z (clockwise from above, i.e. left-hand around Z) + # P rotates around X after H + x = math.sin(h) * math.cos(p) + y = -math.cos(h) * math.cos(p) + z = -math.sin(p) + return Vec3(x, y, z) + + +# ───────────────────────────────────────────────────────────────────────────── +# Per-zone cloud + sky parameters defaults +# (OutdoorLighting zones may override any of these in their profile dict) +# ───────────────────────────────────────────────────────────────────────────── + +_ZONE_SKY_DEFAULTS: dict[str, dict] = { + 'tt': {'cloudCoverage': 0.42, 'cloudSpeed': 0.70, 'cloudSharpness': 0.55, + 'turbidity': 2.5, 'starBrightness': 0.0, 'moonEnabled': False}, + 'dd': {'cloudCoverage': 0.88, 'cloudSpeed': 1.10, 'cloudSharpness': 0.20, + 'turbidity': 5.5, 'starBrightness': 0.0, 'moonEnabled': False}, + 'dg': {'cloudCoverage': 0.28, 'cloudSpeed': 0.55, 'cloudSharpness': 0.70, + 'turbidity': 1.8, 'starBrightness': 0.0, 'moonEnabled': False}, + 'mm': {'cloudCoverage': 0.55, 'cloudSpeed': 0.85, 'cloudSharpness': 0.45, + 'turbidity': 3.5, 'starBrightness': 0.0, 'moonEnabled': False}, + 'br': {'cloudCoverage': 0.78, 'cloudSpeed': 1.40, 'cloudSharpness': 0.15, + 'turbidity': 6.0, 'starBrightness': 0.0, 'moonEnabled': False}, + 'dl': {'cloudCoverage': 0.22, 'cloudSpeed': 0.25, 'cloudSharpness': 0.50, + 'turbidity': 1.5, 'starBrightness': 0.92, 'moonEnabled': True, + 'moonDir': (225, -55, 0)}, + 'gs': {'cloudCoverage': 0.32, 'cloudSpeed': 0.90, 'cloudSharpness': 0.50, + 'turbidity': 3.0, 'starBrightness': 0.0, 'moonEnabled': False}, + 'estate': {'cloudCoverage': 0.38, 'cloudSpeed': 0.60, 'cloudSharpness': 0.55, + 'turbidity': 2.2, 'starBrightness': 0.0, 'moonEnabled': False}, + 'playground': {'cloudCoverage': 0.35, 'cloudSpeed': 0.65, 'cloudSharpness': 0.50, + 'turbidity': 2.4, 'starBrightness': 0.0, 'moonEnabled': False}, + # HQ zones: minimal sky (player rarely sees it indoors/dense area) + 'sellbot_hq': {'cloudCoverage': 0.95, 'cloudSpeed': 0.30, 'cloudSharpness': 0.05, + 'turbidity': 8.0, 'starBrightness': 0.0, 'moonEnabled': False}, + 'cashbot_hq': {'cloudCoverage': 0.85, 'cloudSpeed': 0.40, 'cloudSharpness': 0.10, + 'turbidity': 7.0, 'starBrightness': 0.0, 'moonEnabled': False}, + 'lawbot_hq': {'cloudCoverage': 0.92, 'cloudSpeed': 0.20, 'cloudSharpness': 0.10, + 'turbidity': 7.5, 'starBrightness': 0.0, 'moonEnabled': False}, + 'bossbot_hq': {'cloudCoverage': 0.99, 'cloudSpeed': 0.10, 'cloudSharpness': 0.05, + 'turbidity': 8.0, 'starBrightness': 0.0, 'moonEnabled': False}, + 'cog': {'cloudCoverage': 0.75, 'cloudSpeed': 0.50, 'cloudSharpness': 0.20, + 'turbidity': 6.0, 'starBrightness': 0.0, 'moonEnabled': False}, + # Street variants + 'tt_street': {'cloudCoverage': 0.40, 'cloudSpeed': 0.68, 'cloudSharpness': 0.55, + 'turbidity': 2.4, 'starBrightness': 0.0, 'moonEnabled': False}, + 'dd_street': {'cloudCoverage': 0.90, 'cloudSpeed': 1.20, 'cloudSharpness': 0.18, + 'turbidity': 5.8, 'starBrightness': 0.0, 'moonEnabled': False}, + 'dg_street': {'cloudCoverage': 0.24, 'cloudSpeed': 0.52, 'cloudSharpness': 0.72, + 'turbidity': 1.8, 'starBrightness': 0.0, 'moonEnabled': False}, + 'mm_street': {'cloudCoverage': 0.52, 'cloudSpeed': 0.88, 'cloudSharpness': 0.42, + 'turbidity': 3.6, 'starBrightness': 0.0, 'moonEnabled': False}, + 'br_street': {'cloudCoverage': 0.80, 'cloudSpeed': 1.50, 'cloudSharpness': 0.12, + 'turbidity': 6.2, 'starBrightness': 0.0, 'moonEnabled': False}, + 'dl_street': {'cloudCoverage': 0.20, 'cloudSpeed': 0.22, 'cloudSharpness': 0.48, + 'turbidity': 1.5, 'starBrightness': 0.88, 'moonEnabled': True, + 'moonDir': (225, -55, 0)}, + 'golf_course': {'cloudCoverage': 0.30, 'cloudSpeed': 0.65, 'cloudSharpness': 0.60, + 'turbidity': 2.2, 'starBrightness': 0.0, 'moonEnabled': False}, +} + + +# ───────────────────────────────────────────────────────────────────────────── +# ProceduralSky class +# ───────────────────────────────────────────────────────────────────────────── + +class ProceduralSky: + """Manages a GLSL-driven procedural sky sphere. + + One instance per hood / zone. OutdoorLighting creates and destroys it + alongside the light rig. + """ + + _TASK_NAME = 'proceduralSkyTask' + + def __init__(self) -> None: + self._skyNp: NodePath | None = None + self._time: float = 0.0 + self._activeStyle: str = 'playground' + self._attached: bool = False + + # ── Public API ──────────────────────────────────────────────────────────── + + def attach(self, parent: NodePath, style: str = 'playground') -> None: + """Create the sky sphere and attach it to *parent* (usually camera).""" + if self._attached: + return + shader = _loadSkyShader() + if shader is None: + return # graceful fallback – sky model will be used instead + + try: + self._skyNp = _makeSkySphereMesh() + self._skyNp.reparentTo(parent) + self._skyNp.setDepthTest(False) + self._skyNp.setDepthWrite(False) + self._skyNp.setLightOff(1) + # Ensure the procedural sky is drawn after any legacy/model sky that + # might also live in the background bin. + self._skyNp.setBin('background', 1000) + self._skyNp.setTwoSided(True) + self._skyNp.setShader(shader) + self._skyNp.setShaderAuto(False) + + self._activeStyle = style + self._attached = True + self._time = 0.0 + + # No update task here – OutdoorLighting's main task calls update(). + except Exception as e: + import traceback; traceback.print_exc() + self._skyNp = None + + def update(self, spec: dict, timeOfDay: float = 12.0) -> None: + """Push zone spec parameters as shader uniforms.""" + if not self._attached or self._skyNp is None or self._skyNp.isEmpty(): + return + + self._time += globalClock.getDt() + + # Resolve sky sub-parameters (check spec first, fall back to defaults). + style = self._activeStyle + skyDef = _ZONE_SKY_DEFAULTS.get(style, _ZONE_SKY_DEFAULTS['playground']) + cov = float(spec.get('cloudCoverage', skyDef.get('cloudCoverage', 0.4))) + spd = float(spec.get('cloudSpeed', skyDef.get('cloudSpeed', 0.6))) + sharp = float(spec.get('cloudSharpness', skyDef.get('cloudSharpness', 0.5))) + turb = float(spec.get('turbidity', skyDef.get('turbidity', 2.5))) + stars = float(spec.get('starBrightness', skyDef.get('starBrightness', 0.0))) + moonOn = float(1 if spec.get('moonEnabled', skyDef.get('moonEnabled', False)) else 0) + moonHpr = spec.get('moonDir', skyDef.get('moonDir', (0, -45, 0))) + + # Sun direction: forward vector of key light (direction light shines). + keyHpr = spec.get('keyHpr', (135, -42, 0)) + lightFwd = _hprToDir(keyHpr[0], keyHpr[1]) + # Sun position in sky = opposite of light direction (world space). + sunDirWorld = Vec3(-lightFwd.x, -lightFwd.y, -lightFwd.z) + + moonDirWorld = Vec3(0, 0, 1) + if moonOn > 0.5: + mfwd = _hprToDir(moonHpr[0], moonHpr[1]) + moonDirWorld = Vec3(-mfwd.x, -mfwd.y, -mfwd.z) + + # Must match camera-space ``vDir`` in the shader (dome is under base.cam). + sunDirCam = _dir_world_to_cam(sunDirWorld) + moonDirCam = _dir_world_to_cam(moonDirWorld) + + keyColor = Vec4(*spec.get('key', (1, 1, 1, 1))) + clearColor = spec.get('clearColor', (0.4, 0.6, 0.85, 1.0)) + fogColorV = spec.get('fogColor', (0.6, 0.7, 0.85, 1.0)) + skyScaleV = spec.get('skyScale', (1, 1, 1, 1)) + + # Derive zenith and horizon colours from clearColor and fogColor. + # zenith = clearColor (the "perfect overhead blue") + # horizon = blend clearColor → fogColor + zenith = Vec3(clearColor[0], clearColor[1], clearColor[2]) + horizon = Vec3(fogColorV[0] * 0.85, fogColorV[1] * 0.85, fogColorV[2] * 0.85) + + # Day/night: fade stars from *world* sun elevation (stable when camera tilts). + isDaytime = max(0.0, min(1.0, (sunDirWorld.z + 0.2) * 4.0)) + effectiveStars = stars * (1.0 - isDaytime) + + # Sun blind strength: default 0.85 (strong cinematic glare), clamped 0–1 + blindStr = float(spec.get('sunBlindStrength', 0.85)) + blindStr = max(0.0, min(1.0, blindStr)) + + try: + np = self._skyNp + np.setShaderInput('sunDir', sunDirCam) + np.setShaderInput('sunWorldElev', float(sunDirWorld.z)) + np.setShaderInput('sunColor', keyColor) + np.setShaderInput('zenithColor', zenith) + np.setShaderInput('horizonColor', horizon) + np.setShaderInput('fogColor', Vec3(fogColorV[0], fogColorV[1], fogColorV[2])) + np.setShaderInput('cloudCoverage', cov) + np.setShaderInput('cloudSpeed', spd * _CLOUD_BASE_SPEED) + np.setShaderInput('cloudSharpness', sharp) + np.setShaderInput('turbidity', turb) + np.setShaderInput('starBrightness', effectiveStars) + np.setShaderInput('moonEnabled', moonOn) + np.setShaderInput('moonDir', moonDirCam) + np.setShaderInput('moonColor', Vec4(*spec.get('key', (0.5, 0.6, 1, 1)))) + np.setShaderInput('time', self._time) + np.setShaderInput('skyScale', Vec4(*skyScaleV)) + # New feature uniforms + np.setShaderInput('sunDiscEnabled', 1.0) + np.setShaderInput('sunBlindStrength', blindStr) + except Exception: + pass + + def setStyle(self, style: str) -> None: + """Update which zone sky defaults to use.""" + self._activeStyle = style + + def isActive(self) -> bool: + return self._attached and self._skyNp is not None and not self._skyNp.isEmpty() + + def detach(self) -> None: + """Remove the sky sphere and clean up.""" + if self._skyNp and not self._skyNp.isEmpty(): + self._skyNp.removeNode() + self._skyNp = None + self._attached = False + self._time = 0.0 diff --git a/toontown/hood/TTHood.py b/toontown/hood/TTHood.py index 7571370..1f4206b 100644 --- a/toontown/hood/TTHood.py +++ b/toontown/hood/TTHood.py @@ -51,8 +51,13 @@ class TTHood(ToonHood.ToonHood): def startSpookySky(self): if hasattr(self, 'sky') and self.sky: self.stopSky() - self.sky = loader.loadModel(self.spookySkyFile) - self.sky.setTag('sky', 'Halloween') + # Parent Hood._loadSkyModel handles deleted phase props / ProceduralSky placeholder. + self.sky = self._loadSkyModel(self.spookySkyFile, halloween=True) + try: + if self.sky.getName() in ('legacySkyDisabled', 'missingSkyPlaceholder'): + return + except Exception: + pass self.sky.setScale(1.0) self.sky.setDepthTest(0) self.sky.setDepthWrite(0) diff --git a/toontown/hood/TTHoodDataAI.py b/toontown/hood/TTHoodDataAI.py index 6a3b6b7..d8b5cf7 100644 --- a/toontown/hood/TTHoodDataAI.py +++ b/toontown/hood/TTHoodDataAI.py @@ -6,6 +6,7 @@ from toontown.safezone import TTTreasurePlannerAI from toontown.classicchars import DistributedMickeyAI from toontown.safezone import ButterflyGlobals from direct.task import Task +from toontown.safezone.DistributedTTCCraneSandboxAI import DistributedTTCCraneSandboxAI class TTHoodDataAI(HoodDataAI.HoodDataAI): notify = DirectNotifyGlobal.directNotify.newCategory('TTHoodDataAI') @@ -30,6 +31,10 @@ class TTHoodDataAI(HoodDataAI.HoodDataAI): self.classicChar.generateWithRequired(self.zoneId) self.classicChar.start() self.addDistObj(self.classicChar) + self.ttcCraneSandbox = DistributedTTCCraneSandboxAI(self.air) + self.ttcCraneSandbox.generateWithRequired(self.zoneId) + self.addDistObj(self.ttcCraneSandbox) + self.ttcCraneSandbox.spawnCranesAndSafes() self.createButterflies(ButterflyGlobals.TTC) if simbase.blinkTrolley: taskMgr.doMethodLater(0.5, self._deleteTrolley, 'deleteTrolley') diff --git a/toontown/shaders/post.vert.glsl b/toontown/shaders/post.vert.glsl new file mode 100644 index 0000000..8e6ab54 --- /dev/null +++ b/toontown/shaders/post.vert.glsl @@ -0,0 +1,16 @@ +// Vertex shader for the HDR scene composite quad rendered through a +// 3D orthographic FilterManager camera. Unlike the render2dp overlay +// path (sunrays.vert.glsl), the quad lives in model-space (XZ plane, +// Y=0), so the full ModelViewProjection transform is required to map it +// to clip space correctly. +#version 130 + +uniform mat4 p3d_ModelViewProjectionMatrix; +in vec4 p3d_Vertex; +in vec2 p3d_MultiTexCoord0; +out vec2 uv; + +void main() { + gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex; + uv = p3d_MultiTexCoord0; +} diff --git a/toontown/shaders/scene_post.frag.glsl b/toontown/shaders/scene_post.frag.glsl new file mode 100644 index 0000000..c509ca9 --- /dev/null +++ b/toontown/shaders/scene_post.frag.glsl @@ -0,0 +1,192 @@ +// Scene post-processing composite fragment shader. +// +// Single-pass pipeline that reads a FilterManager-captured scene and applies: +// 1. Depth-buffer-occluded screen-space god rays (Kenny Mitchell technique, +// GPU Gems 3 Ch. 13). Sky pixels (depth ≈ 1.0) let light through; +// solid geometry pixels block it, creating real geometry-cast light shafts. +// 2. Approximate single-pass bloom (bright-pass + large kernel box blur +// at multiple offsets — not physically perfect but fast and convincing). +// 3. ACES filmic tonemapping with per-zone exposure adjustment. +// +// Uniforms set by OutdoorLighting._setupPostProcess(): +// sceneColor – RGBA scene texture (float16 or RGBA8 offscreen; exposure+ACES below) +// sceneDepth – depth texture matching sceneColor dimensions +// sunScreenPos – sun position in [0,1] screen UV space +// rayColor – god-ray tint (RGBA) +// rayIntensity – master ray strength (0 = off) +// bloomIntensity – bloom strength (0 = off) +// bloomThreshold – luminance threshold for bright-pass +// exposure – scene exposure scalar (default 1.0) +// tonemapEnabled – 0 = bypass tonemap (linear output), 1 = ACES +// vignetteStrength – 0 = off, 0.25 = subtle, 1 = strong +// time – animation seconds (unused here; reserved for shimmer) +// texelSize – vec2(1/width, 1/height) for blur offsets +#version 130 + +uniform sampler2D sceneColor; +uniform sampler2D sceneDepth; +uniform vec2 sunScreenPos; +uniform vec4 rayColor; +uniform float rayIntensity; +// Bloom is temporarily disabled in code to avoid a Panda3D shader input +// assertion on some drivers; keep the effect path available for later. +uniform float exposure; +uniform float tonemapEnabled; +uniform float vignetteStrength; +uniform float time; +uniform vec2 texelSize; + +in vec2 uv; +out vec4 fragColor; + +// ── ACES filmic tonemapper ─────────────────────────────────────────────────── +// Fitted curve by Krzysztof Narkowicz (2015). Very close to the full ACES +// reference at a fraction of the cost. +vec3 acesTonemap(vec3 x) { + const float a = 2.51; + const float b = 0.03; + const float c = 2.43; + const float d = 0.59; + const float e = 0.14; + return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0); +} + +// ── Reinhard (per-channel) ─────────────────────────────────────────────────── +vec3 reinhardTonemap(vec3 x) { + return x / (1.0 + x); +} + +// ── Luminance helper ───────────────────────────────────────────────────────── +float luminance(vec3 c) { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +// ── Screen-space depth-occluded god rays ───────────────────────────────────── +// +// Algorithm: march from the current pixel toward the sun position in screen +// space (NUM_SAMPLES steps). At each step sample the depth buffer. +// depth == 1.0 (or very close) → sky pixel → sun is visible → accumulate +// depth < DEPTH_THRESHOLD → geometry pixel → occluded → skip +// +// The accumulated value is weighted by an exponential decay so samples nearer +// the sun contribute more. The classic GPU Gems 3 weighting applies. +vec3 godRays(vec2 pixelUV, float intensity) { + if (intensity <= 0.001) return vec3(0.0); + + const int NUM_SAMPLES = 96; + const float DECAY = 0.966; + const float DENSITY = 0.84; + const float WEIGHT = 0.45; + const float EXPOSURE_RAY = 0.16; + const float DEPTH_THRESHOLD = 0.9998; // sky depth threshold + + // Cull when the sun is completely off-screen to avoid aliasing artefacts. + vec2 sunEdgeDist = min(sunScreenPos, 1.0 - sunScreenPos); + float edgeFade = smoothstep(0.0, 0.08, min(sunEdgeDist.x, sunEdgeDist.y)); + if (edgeFade <= 0.0) return vec3(0.0); + + vec2 delta = (pixelUV - sunScreenPos) * (DENSITY / float(NUM_SAMPLES)); + vec2 sampleUV = pixelUV; + float illum = 0.0; + float decay = 1.0; + + for (int i = 0; i < NUM_SAMPLES; ++i) { + sampleUV -= delta; + // Clamp so we don't sample outside the texture. + vec2 cUV = clamp(sampleUV, vec2(0.001), vec2(0.999)); + float d = texture(sceneDepth, cUV).r; + // Sky pixels (d ≥ DEPTH_THRESHOLD) are unoccluded → contribute. + float sky = step(DEPTH_THRESHOLD, d); + illum += sky * decay * WEIGHT; + decay *= DECAY; + } + illum *= EXPOSURE_RAY * intensity * edgeFade; + + return rayColor.rgb * illum; +} + +// ── Single-pass approximate bloom ─────────────────────────────────────────── +// +// Extracts bright pixels then blurs with a two-ring sample pattern (Poisson +// disc approximation). Not as smooth as multi-pass Gaussian but avoids the +// need for ping-pong buffers, keeping us in one FilterManager pass. +vec3 bloom(vec2 pixUV, float threshold, float intensity) { + if (intensity <= 0.001) return vec3(0.0); + + vec3 acc = vec3(0.0); + float total = 0.0; + + // Two rings: inner (4 samples) + outer (8 samples) + // Offsets are in texel units; scale drives blur radius. + float blurRadius = mix(3.0, 9.0, intensity); + + vec2 offsets[12]; + // Inner ring + offsets[0] = vec2( 1.0, 0.0); + offsets[1] = vec2(-1.0, 0.0); + offsets[2] = vec2( 0.0, 1.0); + offsets[3] = vec2( 0.0, -1.0); + // Mid ring + offsets[4] = vec2( 1.5, 1.5); + offsets[5] = vec2(-1.5, 1.5); + offsets[6] = vec2( 1.5, -1.5); + offsets[7] = vec2(-1.5, -1.5); + // Outer ring + offsets[8] = vec2( 3.0, 0.0); + offsets[9] = vec2(-3.0, 0.0); + offsets[10] = vec2( 0.0, 3.0); + offsets[11] = vec2( 0.0, -3.0); + + float weights[12]; + weights[0] = 1.00; weights[1] = 1.00; + weights[2] = 1.00; weights[3] = 1.00; + weights[4] = 0.70; weights[5] = 0.70; + weights[6] = 0.70; weights[7] = 0.70; + weights[8] = 0.35; weights[9] = 0.35; + weights[10] = 0.35; weights[11] = 0.35; + + for (int i = 0; i < 12; ++i) { + vec2 sUV = pixUV + offsets[i] * texelSize * blurRadius; + vec3 col = texture(sceneColor, sUV).rgb; + float lum = luminance(col); + float bright = max(0.0, lum - threshold); + acc += col * bright * weights[i]; + total += weights[i]; + } + if (total > 0.0) acc /= total; + + return acc * intensity * 1.4; +} + +// ── Vignette ──────────────────────────────────────────────────────────────── +float vignette(vec2 u, float strength) { + vec2 d = u - 0.5; + return 1.0 - dot(d, d) * strength * 3.2; +} + +// ── Main ───────────────────────────────────────────────────────────────────── +void main() { + vec4 sceneRGBA = texture(sceneColor, uv); + vec3 col = sceneRGBA.rgb * exposure; + + // ── God rays ───────────────────────────────────────────────────────────── + col += godRays(uv, rayIntensity); + + // ── Bloom ──────────────────────────────────────────────────────────────── + // Disabled for now (see note above). + col += bloom(uv, 1.0, 0.0); + + // ── Tonemapping ────────────────────────────────────────────────────────── + if (tonemapEnabled > 0.5) { + col = acesTonemap(col); + } else { + col = clamp(col, 0.0, 1.0); + } + + // ── Vignette ───────────────────────────────────────────────────────────── + if (vignetteStrength > 0.001) { + col *= clamp(vignette(uv, vignetteStrength), 0.0, 1.0); + } + + fragColor = vec4(col, sceneRGBA.a); +} diff --git a/toontown/shaders/sky.frag.glsl b/toontown/shaders/sky.frag.glsl new file mode 100644 index 0000000..170e78d --- /dev/null +++ b/toontown/shaders/sky.frag.glsl @@ -0,0 +1,440 @@ +// Procedural physically-based sky fragment shader – MASSIVE REWRITE +// +// Implements (new and improved): +// • Rayleigh + Mie scattering – accurate wavelength-dependent blue-sky gradient +// • Wide sunset/sunrise horizon corona (orange-pink band near horizon) +// • Ozone absorption – cyan/yellow sky transition near sunset +// • Horizon haze / atmospheric extinction with turbidity control +// • VISIBLE SUN DISC with: +// – Physical angular radius (~0.27°) +// – Limb darkening (edges slightly darker than centre) +// – Colour shifts noon→white-yellow, sunset→deep orange-red +// – Multi-layer exponential corona / aureole +// – Subtle vertical lens-flare streak +// – BLINDING GLARE when staring directly into the sun (chromatic aberration glow) +// • Volumetric-looking FBM cumulus clouds with domain warping: +// – Two-pass domain warp (organic, non-repetitive shapes) +// – Multi-layer depth sampling (distinct top/middle/base layers) +// – Sun back-lighting (silver lining on edges facing sun) +// – Sunset orange-pink underbelly glow +// – Moon-lit night-time clouds (cool blue-grey) +// – Per-zone coverage, speed, sharpness +// • Twinkling star field: +// – Per-star unique twinkle frequency & phase +// – Four spectral classes: blue-white / white / yellow-white / warm +// – Occasional "sparkle" diffraction cross on bright stars +// – Fades behind clouds and near the sun +// • MOON disc with: +// – Surface noise detail (craters) +// – Limb darkening +// – Multi-layer corona glow +// – Cloud occlusion +// • Zone colour scale (skyScale) applied to final output +// • Reinhard tonemapping + gamma lift for natural HDR-to-LDR +// +// Uniforms set by ProceduralSky.update(): +// sunDir – world-space direction TOWARD the sun (normalised) +// sunColor – key light colour; used for Mie glow tint +// zenithColor – deep sky colour at zenith (zone) +// horizonColor – sky colour at horizon (zone) +// fogColor – atmospheric haze/fog tint (zone) +// cloudCoverage – 0.0 (clear) … 1.0 (overcast) (zone) +// cloudSpeed – cloud animation multiplier (zone) +// cloudSharpness – edge sharpness 0.0 (fluffy) … 1.0 (sharp) (zone) +// turbidity – Mie strength 1.0 (crisp) … 8.0 (hazy) (zone) +// starBrightness – star intensity; 0 in day, up to 1 at night (zone) +// moonEnabled – 1.0 = draw a moon disc +// moonDir – world-space direction toward moon +// moonColor – moon disc tint +// time – animation seconds (drives cloud drift + star twinkle) +// skyScale – vec4 colour multiplier (zone tint) +// sunDiscEnabled – 1.0 = draw visible sun disc (default 1) +// sunBlindStrength – 0.0–1.0 blinding glare when staring into sun +// sunWorldElev – sun direction Z in *world* space [-1..1] (time-of-day); +// separate from sunDir so atmosphere does not swim when the camera tilts +#version 130 + +// ── Atmosphere uniforms ──────────────────────────────────────────────────── +uniform vec3 sunDir; +uniform float sunWorldElev; +uniform vec4 sunColor; +uniform vec3 zenithColor; +uniform vec3 horizonColor; +uniform vec3 fogColor; +// ── Cloud uniforms ───────────────────────────────────────────────────────── +uniform float cloudCoverage; +uniform float cloudSpeed; +uniform float cloudSharpness; +// ── Scattering ──────────────────────────────────────────────────────────── +uniform float turbidity; +// ── Night sky ───────────────────────────────────────────────────────────── +uniform float starBrightness; +uniform float moonEnabled; +uniform vec3 moonDir; +uniform vec4 moonColor; +// ── Animation ───────────────────────────────────────────────────────────── +uniform float time; +uniform vec4 skyScale; +// ── New features ────────────────────────────────────────────────────────── +uniform float sunDiscEnabled; // 1.0 = render sun disc + corona +uniform float sunBlindStrength; // 0–1 glare when staring at sun + +in vec3 vDir; +in vec2 vUV; +out vec4 fragColor; + +// ───────────────────────────────────────────────────────────────────────── +// Noise / hash utilities +// ───────────────────────────────────────────────────────────────────────── + +float _hash(vec2 p) { + p = fract(p * vec2(127.1, 311.7)); + p += dot(p, p + 19.19); + return fract(p.x * p.y); +} + +float _vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(_hash(i), _hash(i + vec2(1.0, 0.0)), u.x), + mix(_hash(i + vec2(0.0, 1.0)), _hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); +} + +// 8-octave FBM with per-octave rotation (breaks axis-aligned repetition) +float _fbm(vec2 p) { + float v = 0.0; + float amp = 0.5; + mat2 rot = mat2(1.6, 1.2, -1.2, 1.6); + for (int i = 0; i < 8; ++i) { + v += amp * _vnoise(p); + p = rot * p * 2.1; + amp *= 0.46; + } + return v; +} + +// ───────────────────────────────────────────────────────────────────────── +// Phase functions +// ───────────────────────────────────────────────────────────────────────── + +float _mie(float cosA, float g) { + float g2 = g * g; + return (1.0 - g2) / pow(max(1.0e-4, 1.0 + g2 - 2.0 * g * cosA), 1.5) * 0.25; +} + +float _rayleigh(float cosA) { + return 0.75 * (1.0 + cosA * cosA); +} + +// ───────────────────────────────────────────────────────────────────────── +// Main +// ───────────────────────────────────────────────────────────────────────── + +void main() { + vec3 dir = normalize(vDir); + float elev = dir.z; + float elevAbs = abs(elev); + float cosTheta = dot(dir, sunDir); + // World-space sun height (stable when camera pitches/yaws); sunDir is camera-space for dots. + float sunElevW = sunWorldElev; + // sunPower: 0 when sun is on or below horizon, rises to 1 near zenith + float sunPower = clamp(sunElevW * 3.5 + 0.28, 0.0, 1.0); + + // ── Rayleigh scattering ────────────────────────────────────────────── + // Wavelength-dependent (shorter λ = blue scatters more) + vec3 rayleigh_wl = vec3(0.26, 0.50, 1.00); + float rayleighPhase = _rayleigh(cosTheta); + vec3 rayleighCol = rayleigh_wl * rayleighPhase * mix(0.28, 1.05, sunPower); + + // ── Mie scattering (turbidity-driven forward halo) ─────────────────── + float g = max(0.58, 0.84 - turbidity * 0.020); + float mieStr = turbidity * 0.13; + float mieGlow = _mie(cosTheta, g) * mieStr; + vec3 mieCol = sunColor.rgb * mieGlow; + + // ── Wide horizon corona at sunset/sunrise ──────────────────────────── + // Horizontal dot: measures how closely dir aligns with sun's azimuth at horizon + vec2 sunAz = vec2(sunDir.x, sunDir.y); + float sunAzLen = max(0.001, length(sunAz)); + float horizDot = dot(vec2(dir.x, dir.y), sunAz / sunAzLen); + float horizBand = pow(max(0.0, horizDot), 3.5) + * max(0.0, 1.0 - abs(sunElevW) * 3.8) // only near horizon + * (1.0 - abs(elev) * 3.0) // fade away from horizon line + * 0.70; + vec3 horizGlowCol = mix( + vec3(1.00, 0.42, 0.06), // deep orange at low elevation + vec3(1.00, 0.80, 0.42), // golden higher up + clamp(sunPower * 1.5, 0.0, 1.0) + ) * horizBand; + + // ── Ozone absorption ───────────────────────────────────────────────── + float ozone = max(0.0, 1.0 - elevAbs * 1.55); + vec3 ozoneCol = vec3(0.00, 0.13, 0.22) * ozone * sunPower; + + // ── Sky gradient (zenith → horizon) ────────────────────────────────── + float horizonT = pow(clamp(elev * 1.25 + 0.14, 0.0, 1.0), 0.50); + vec3 gradCol = mix(horizonColor, zenithColor, horizonT); + + // Sunset/sunrise band: orange-pink near horizon, purple higher up + float sunsetBand = max(0.0, 1.0 - abs(sunElevW) * 2.2) * (1.0 - horizonT * 0.75); + vec3 sunsetTint = mix( + vec3(1.00, 0.38, 0.05), // near-horizon orange + vec3(0.55, 0.25, 0.72), // purple higher + horizonT + ) * sunsetBand * 0.62; + gradCol += sunsetTint; + + // Assemble base sky + vec3 sky = gradCol + + rayleighCol * 0.42 + + mieCol + + ozoneCol + + horizGlowCol; + + // ── Horizon haze (atmospheric extinction) ──────────────────────────── + float hazeT = exp(-max(0.0, elev) * 5.2 * turbidity * 0.27); + sky = mix(sky, fogColor, hazeT * 0.52); + + // ── Below horizon: fade to dark fog ────────────────────────────────── + if (elev < 0.0) { + float below = clamp(-elev * 9.5, 0.0, 1.0); + sky = mix(sky, fogColor * 0.48, below); + } + + // ───────────────────────────────────────────────────────────────────── + // Volumetric clouds + // ───────────────────────────────────────────────────────────────────── + + float cloudAlpha = 0.0; + vec3 cloudRGB = vec3(1.0); + + if (cloudCoverage > 0.02 && elev > -0.07) { + // Perspective projection onto cloud layer at ~1 km altitude + float layerScale = 1.0 / max(0.05, elev + 0.05); + vec2 cloudUV = vec2(dir.x, dir.y) * layerScale * 0.36 + + vec2(time * cloudSpeed * 0.00115, time * cloudSpeed * 0.00045); + + // ── Domain warping pass 1 (large-scale organic distortion) ─────── + vec2 warp1 = vec2( + _fbm(cloudUV * 1.55), + _fbm(cloudUV * 1.55 + vec2(5.20, 1.30)) + ) * 0.32; + + // ── Domain warping pass 2 (medium-scale detail) ────────────────── + vec2 warp2 = vec2( + _fbm(cloudUV * 0.82 + vec2(1.70, 9.20)), + _fbm(cloudUV * 0.82 + vec2(8.30, 2.80)) + ) * 0.14; + + vec2 warpedUV = cloudUV + warp1 + warp2; + + // Primary cloud density field + float density = _fbm(warpedUV * 2.35); + + // Coverage → threshold + float threshold = 1.0 - cloudCoverage * 0.76; + float raw = density - threshold; + float edgeWidth = mix(0.32, 0.045, cloudSharpness); + float shaped = smoothstep(0.0, edgeWidth, raw); + + // Horizon fade (perspective stretch makes low-angle clouds blur badly) + shaped *= smoothstep(-0.04, 0.18, elev); + + // ── Multi-layer depth sampling for 3-D cloud body feel ────────── + float densityMid = _fbm(warpedUV * 4.00 + vec2(1.70, 3.10)); + float densityFine = _fbm(warpedUV * 8.50 + vec2(-2.30, 0.80)); + // volDepth: 0 = outer edge, 1 = deep interior + float volDepth = clamp(densityMid * 0.45 + densityFine * 0.18, 0.0, 1.0); + + // ── Cloud illumination ─────────────────────────────────────────── + float sunDot = max(0.0, dot(dir, sunDir)); + float shadowing = clamp(1.0 - shaped * 0.68, 0.16, 1.0); + + // Top surface: brightly lit by direct sun + vec3 litTop = mix(vec3(0.96, 0.97, 1.00), sunColor.rgb * 1.20, 0.20); + + // Cloud base: deeper interior → darker grey-blue shadow + float depthSh = mix(0.36, 0.62, volDepth); + vec3 litBase = litTop * vec3(depthSh * 0.88, depthSh * 0.93, depthSh * 1.04); + + // Sunset underbelly: orange-pink glow when sun is near horizon + float sunsetC = max(0.0, 1.0 - abs(sunElevW) * 4.0) * sunPower; + vec3 sunsetBelly = mix( + vec3(1.0, 0.55, 0.22), + vec3(1.0, 0.78, 0.52), + clamp(sunDot, 0.0, 1.0) + ) * sunsetC * 0.75; + litBase += sunsetBelly; + + // Second FBM sample for vertical shading variation + float baseShade = _fbm(warpedUV * 2.35 + vec2(0.30, 0.15)) * 0.72 + 0.28; + cloudRGB = mix(litBase, litTop, baseShade * shadowing); + + // Silver lining: bright backlit halo on sun-facing cloud edges + float silverEdge = smoothstep(edgeWidth * 0.55, 0.0, raw) * sunDot; + cloudRGB += vec3(0.72, 0.64, 0.46) * silverEdge * sunPower * 0.70; + + // Night / moon-lit clouds (cool dim blue-grey) + if (moonEnabled > 0.5 && sunPower < 0.30) { + float mnFade = clamp((0.30 - sunPower) * 4.0, 0.0, 1.0); + float mnDot = max(0.0, dot(dir, normalize(moonDir))); + vec3 mnLight = vec3(0.35, 0.42, 0.60) * mnDot * 0.38; + float nDepth = mix(0.06, 0.52, shaped); + cloudRGB = mix(cloudRGB, vec3(0.05, 0.07, 0.14) + mnLight * shaped, + mnFade * nDepth); + } + + cloudAlpha = shaped; + } + + sky = mix(sky, cloudRGB, cloudAlpha); + + // ───────────────────────────────────────────────────────────────────── + // Twinkling star field + // ───────────────────────────────────────────────────────────────────── + + if (starBrightness > 0.004 && elev > 0.02) { + // Quantise direction → star cells (each has at most 1 star) + vec3 snap = floor(dir * 210.0) / 210.0; + float seed = _hash(snap.xy * vec2(43.0, 127.0) + snap.z * 59.0); + + // Star brightness / size (varies per cell) + float szSeed = _hash(snap.yx * vec2(71.0, 23.0) + snap.z * 17.0); + float exponent = mix(430.0, 680.0, szSeed); + float rawBrt = pow(seed, exponent) * mix(2.4, 5.2, szSeed); + + // ── Twinkle: per-star unique frequency and phase ───────────────── + float twSeed = _hash(snap.xy * 33.0 + snap.z * 71.0); + float twFreq = mix(0.35, 4.0, twSeed); + float twPhase = twSeed * 6.28318530; + // Primary twinkle + float twinkle = 0.62 + 0.38 * sin(time * twFreq + twPhase); + // Secondary high-frequency shimmer on bright stars + float shimmer = 1.0 + 0.15 * sin(time * twFreq * 3.3 + twPhase * 1.7); + float star = rawBrt * twinkle * shimmer; + + // ── Star spectral class ────────────────────────────────────────── + float colSeed = _hash(snap.yz * vec2(37.0, 53.0)); + vec3 starCol; + if (colSeed < 0.22) starCol = vec3(0.76, 0.84, 1.00); // O/B blue-white + else if (colSeed < 0.48) starCol = vec3(1.00, 1.00, 0.94); // A/F white + else if (colSeed < 0.74) starCol = vec3(1.00, 0.95, 0.70); // G yellow-white + else starCol = vec3(1.00, 0.75, 0.55); // K/M warm orange + + // Dim near sun's position in sky + float nearSun = max(0.0, dot(dir, sunDir)); + star *= max(0.0, 1.0 - nearSun * nearSun * 4.0); + + // Fade behind clouds + star *= max(0.0, 1.0 - cloudAlpha * 1.2); + + sky += starCol * star * starBrightness; + + // ── Occasional bright "sparkle" with diffraction cross ─────────── + if (seed > 0.9982) { + float sparkle = (seed - 0.9982) / 0.0018; + sparkle = sparkle * sparkle * twinkle * twinkle; + // Cross arms: exponential falloff from snap position + float crossW = 0.0055; + float arm_h = exp(-abs(dir.x - snap.x) / crossW); + float arm_v = exp(-abs(dir.y - snap.y) / crossW); + float cross2 = (arm_h + arm_v) * 0.5; + sky += starCol * cross2 * sparkle * starBrightness * 0.50 + * max(0.0, 1.0 - cloudAlpha * 1.5); + } + } + + // ───────────────────────────────────────────────────────────────────── + // Sun disc + corona + blinding glare + // ───────────────────────────────────────────────────────────────────── + + if (sunDiscEnabled > 0.5 && sunElevW > -0.14) { + float angDist = acos(clamp(cosTheta, -1.0, 1.0)); + float sunR = 0.0048; // angular radius of sun disc (~0.275°) + + // ── Solar disc with limb darkening ─────────────────────────────── + float limbT = clamp(1.0 - angDist / sunR, 0.0, 1.0); + float limb = smoothstep(0.0, 1.0, limbT) * step(angDist, sunR * 1.30); + float limbDrk = mix(0.68, 1.0, limbT); // edges 32% darker than centre + + // Colour: white-yellow at noon → deep orange-red at horizon + float sunsetT = clamp(1.0 - sunElevW * 5.5, 0.0, 1.0); + vec3 discCol = mix( + vec3(1.00, 0.97, 0.82) * 5.0, // noon: brilliant white-yellow + vec3(1.00, 0.44, 0.05) * 2.8, // sunset: deep orange-red + sunsetT + ) * limbDrk; + + // ── Multi-layer corona / aureole ───────────────────────────────── + float c1 = exp(-angDist * 340.0) * 1.60; + float c2 = exp(-angDist * 95.0) * 0.70; + float c3 = exp(-angDist * 30.0) * 0.32; + float c4 = exp(-angDist * 8.5) * 0.12; + vec3 coronaCol = sunColor.rgb * (c1 + c2 + c3 + c4) * sunPower; + + // ── Vertical diffraction streak ────────────────────────────────── + float streak = exp(-abs(dir.z - sunDir.z) * 90.0) + * exp(-max(0.0, 1.0 - cosTheta) * 150.0) + * 0.28 * sunPower; + vec3 streakCol = sunColor.rgb * streak; + + // ── Blinding glare + chromatic aberration ──────────────────────── + float blindAmt = pow(max(0.0, cosTheta), 55.0) * sunBlindStrength * sunPower; + // Slightly wider red channel for chromatic effect + vec3 blindCol = vec3( + pow(max(0.0, cosTheta), 38.0) * sunBlindStrength * sunPower * 1.35, + blindAmt * 0.90, + blindAmt * 0.65 + ); + + // ── Visibility: blocked by clouds, clipped below horizon ───────── + float sunVis = (1.0 - cloudAlpha * 0.93) + * clamp((sunElevW + 0.12) * 7.5, 0.0, 1.0); + + sky += (discCol * limb + coronaCol + streakCol + blindCol) * sunVis; + } + + // ───────────────────────────────────────────────────────────────────── + // Moon disc + surface detail + corona + // ───────────────────────────────────────────────────────────────────── + + if (moonEnabled > 0.5) { + vec3 mDir = normalize(moonDir); + float moonDot = dot(dir, mDir); + float moonR = 0.9990; // cos(~2.56°) + float moonDisc = smoothstep(moonR, moonR + 0.0008, moonDot); + + // Surface noise → subtle crater/mare variation + vec3 mTang = normalize(dir - mDir * moonDot); + float mDetail = _vnoise(vec2(mTang.x * 550.0 + 30.0, + mTang.z * 550.0 + 70.0)) * 0.18 + 0.82; + + // Limb darkening + float mLimb = mix(0.65, 1.0, + clamp(1.0 - max(0.0, 1.0 - moonDot) * 3.0, 0.0, 1.0)); + vec3 moonSurf = vec3(0.88, 0.88, 0.80) * mDetail * mLimb; + + // Multi-layer glow + float mH1 = exp(-max(0.0, 1.0 - moonDot) * 110.0) * 0.30; + float mH2 = exp(-max(0.0, 1.0 - moonDot) * 24.0) * 0.09; + float mH3 = exp(-max(0.0, 1.0 - moonDot) * 6.0) * 0.03; + vec3 moonGlow = moonColor.rgb * (mH1 + mH2 + mH3) * 0.85; + + float moonVis = max(0.0, 1.0 - cloudAlpha * 0.90); + sky = mix(sky, moonSurf * 1.55, moonDisc * moonVis); + sky += moonGlow * moonVis; + } + + // ── Zone colour scale ───────────────────────────────────────────────── + sky *= skyScale.rgb; + + // ── Tonemapping: Reinhard + mild gamma lift ─────────────────────────── + sky = sky / (sky + vec3(0.72)); // soft knee HDR clamp + sky = pow(clamp(sky, 0.0, 1.0), vec3(1.0 / 1.15)); // slight gamma lift + + fragColor = vec4(sky, 1.0); +} diff --git a/toontown/shaders/sky.vert.glsl b/toontown/shaders/sky.vert.glsl new file mode 100644 index 0000000..74dc354 --- /dev/null +++ b/toontown/shaders/sky.vert.glsl @@ -0,0 +1,32 @@ +// Procedural sky sphere vertex shader. +// +// The sky is rendered on a large sphere (~950 unit radius) that follows the +// camera. Because the sphere is centred at the camera in model space, every +// vertex position is already a world-space ray direction. The fragment shader +// uses that direction to compute atmospheric scattering colour and clouds. +// +// Panda3D coordinate convention: Y = forward, Z = up, X = right. +#version 130 + +in vec4 p3d_Vertex; +uniform mat4 p3d_ModelViewProjectionMatrix; + +out vec3 vDir; // unnormalised model-space direction (normalised in frag) +out vec2 vUV; // model-space polar UV for cloud tiling + +void main() { + gl_Position = (p3d_ModelViewProjectionMatrix * p3d_Vertex).xyww; + // xyww trick forces depth to 1.0 (far clip) in NDC so sky is always behind + // geometry — no depth write needed, but this makes the depth test pass even + // without disabling depth write on the NodePath. + + vDir = p3d_Vertex.xyz; + + // Spherical UV: longitude (azimuth) on X, latitude (elevation) on Y. + // Used for cloud layer texture-coordinate calculation in the fragment shader. + float len = length(p3d_Vertex.xyz); + vec3 d = p3d_Vertex.xyz / max(len, 0.001); + float phi = atan(d.x, d.y); // azimuth [-π, π] + float theta = asin(clamp(d.z, -1.0, 1.0)); // elevation [-π/2, π/2] + vUV = vec2(phi / 6.28318530 + 0.5, theta / 3.14159265 + 0.5); +} diff --git a/toontown/shaders/sunrays.frag.glsl b/toontown/shaders/sunrays.frag.glsl new file mode 100644 index 0000000..25e29dd --- /dev/null +++ b/toontown/shaders/sunrays.frag.glsl @@ -0,0 +1,119 @@ +// Procedural radial god-ray / sun-shaft overlay. +// +// Renders additive volumetric light streaks radiating from a sun or moon +// position in screen-space UV. Designed for Panda3D additive blending on a +// fullscreen CardMaker quad in render2dp. +// +// Uniforms (set by OutdoorLighting._createGodRaysOverlay / _applyProfileLive): +// sunPos – sun/moon position in [0,1] screen UV space +// rayColor – base tint colour of the rays (RGBA) +// rayIntensity – master scale (0..1+, boosted during golden hour / sunset) +// aspectRatio – window width / height (keeps rays radially symmetric) +// time – seconds since scene start (drives subtle shimmer) +#version 130 + +uniform vec2 sunPos; +uniform vec4 rayColor; +uniform float rayIntensity; +uniform float aspectRatio; +uniform float time; + +in vec2 uv; +out vec4 fragColor; + +// ── Fast hash functions (no trig) ──────────────────────────────────────────── +float hash11(float p) { + p = fract(p * 0.1031); + p *= p + 33.33; + p *= p + p; + return fract(p); +} +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +void main() { + // Aspect-correct delta from sun position. + vec2 aspect = vec2(aspectRatio, 1.0); + vec2 delta = (uv - sunPos) * aspect; + float dist = length(delta); + float angle = atan(delta.y, delta.x); + + // ── Radial streaks ─────────────────────────────────────────────────── + // 20 streaks with per-streak random angular width, stretch and shimmer. + const int NUM_STREAKS = 20; + float streaks = 0.0; + for (int i = 0; i < NUM_STREAKS; i++) { + float fi = float(i); + + float streakAngle = fi / float(NUM_STREAKS) * 6.28318530; + float baseWidth = 0.010 + hash11(fi * 3.71) * 0.024; + float stretch = 2.2 + hash11(fi * 7.43) * 5.0; + + // Each streak breathes independently at a different frequency. + float shimmer = 1.0 + 0.07 * sin(time * (1.0 + hash11(fi) * 2.4) + fi * 1.3); + float angularWidth = baseWidth * shimmer; + + // Wrap angular difference to [-π, π]. + float diff = angle - streakAngle; + diff = diff - 6.28318530 * floor((diff + 3.14159265) / 6.28318530); + + float gaussian = exp(-(diff * diff) / (2.0 * angularWidth * angularWidth)); + float lenFade = exp(-dist * stretch); + + streaks += gaussian * lenFade; + } + + // ── Diffuse halo + tight corona ────────────────────────────────────── + float halo = exp(-dist * 8.5) * 0.70; + float corona = pow(max(0.0, 1.0 - dist * 4.2), 3.8) * 0.40; + + float total = streaks + halo + corona; + + // ── Feathering ─────────────────────────────────────────────────────── + // Suppress artefacts immediately around the source and at screen edges. + float nearFade = smoothstep(0.0, 0.05, dist); + + vec2 edgeDist = min(sunPos, 1.0 - sunPos); + float edgeFade = smoothstep(0.0, 0.10, min(edgeDist.x, edgeDist.y)); + + total *= nearFade * edgeFade * rayIntensity; + + // ── Subtle chromatic fringe ────────────────────────────────────────── + // The RGB channels are sampled at slightly offset radii, producing a + // thin prismatic ring around the corona. It adds cinematic atmosphere + // without being garish. The effect is strongest near the source and + // fades with distance. + float fringeMask = exp(-dist * 12.0) * 0.18; + float rOffset = 0.004 * aspectRatio; + float bOffset = -0.004 * aspectRatio; + vec2 rDir = normalize(delta + vec2(0.001)) * rOffset; + vec2 bDir = normalize(delta + vec2(0.001)) * bOffset; + + // Re-evaluate total at offset positions for R and B channels. + vec2 deltaR = (uv + rDir - sunPos) * aspect; + float distR = length(deltaR); + float coronaR = pow(max(0.0, 1.0 - distR * 4.2), 3.8) * 0.40; + float haloR = exp(-distR * 8.5) * 0.70; + + vec2 deltaB = (uv + bDir - sunPos) * aspect; + float distB = length(deltaB); + float coronaB = pow(max(0.0, 1.0 - distB * 4.2), 3.8) * 0.40; + float haloB = exp(-distB * 8.5) * 0.70; + + float fringeR = (coronaR + haloR) * nearFade * edgeFade * rayIntensity; + float fringeB = (coronaB + haloB) * nearFade * edgeFade * rayIntensity; + + // Blend chromatic fringe into main signal. + vec3 colour; + colour.r = rayColor.r * mix(total, fringeR, fringeMask); + colour.g = rayColor.g * total; + colour.b = rayColor.b * mix(total, fringeB, fringeMask); + + // Premultiplied alpha for the additive blend mode. + float alpha = (colour.r + colour.g + colour.b) / 3.0 * rayColor.a; + + fragColor = vec4(colour, alpha); +} diff --git a/toontown/shaders/sunrays.vert.glsl b/toontown/shaders/sunrays.vert.glsl new file mode 100644 index 0000000..32b0a5a --- /dev/null +++ b/toontown/shaders/sunrays.vert.glsl @@ -0,0 +1,12 @@ +// Fullscreen pass-through vertex shader used for god-ray and atmospheric +// overlay effects rendered on a fullscreen CardMaker quad in render2dp. +#version 130 + +in vec4 p3d_Vertex; +in vec2 p3d_MultiTexCoord0; +out vec2 uv; + +void main() { + gl_Position = p3d_Vertex; + uv = p3d_MultiTexCoord0; +} diff --git a/toontown/shaders/water.frag.glsl b/toontown/shaders/water.frag.glsl new file mode 100644 index 0000000..06f4553 --- /dev/null +++ b/toontown/shaders/water.frag.glsl @@ -0,0 +1,166 @@ +// Water surface fragment shader. +// +// Technique overview +// ────────────────── +// 1. Two-layer procedural gradient noise builds a per-pixel wave normal. +// No texture atlas required – the entire effect is analytic. +// 2. Fresnel equation (Schlick approximation) blends between: +// • deep-water refraction colour (zone-specific waterColor tint) +// • planar reflection texture sampled with distortion +// 3. Phong specular on top gives the sun-glint / sparkle. +// 4. A subtle edge-foam brightening is derived from world-space position +// (no depth buffer needed – approximated by a modulated wave term). +// 5. Alpha is 0.88 so the reflection isn't opaque and water stays readable +// even without depth sorting. +// +// Uniforms set by OutdoorLighting._setupWaterNode / _tickWaterUniforms: +// osl_ReflectionTex – planar reflection render-to-texture +// osl_WaterColor – (r,g,b,a) zone water tint / refraction base +// osl_SunColor – key light colour for specular +// osl_SunDir – normalised direction *toward* the sun (world space) +// osl_CameraPos – camera world position (for Fresnel / specular) +// osl_Time – seconds (animation) +// osl_WaveScale – UV tiling scale matching the vertex stage +// osl_WaveSpeed – animation speed multiplier +// osl_FresnelPower – Fresnel exponent (3–5 is physically plausible) +// osl_Roughness – wave normal perturbation strength (0.2–0.6) +// osl_ReflectionStrength – master reflection blend weight (0.0–1.0) +#version 130 + +uniform sampler2D osl_ReflectionTex; +uniform vec4 osl_WaterColor; +uniform vec4 osl_SunColor; +uniform vec3 osl_SunDir; +uniform vec3 osl_CameraPos; +uniform float osl_Time; +uniform float osl_WaveScale; +uniform float osl_WaveSpeed; +uniform float osl_FresnelPower; +uniform float osl_Roughness; +uniform float osl_ReflectionStrength; + +in vec2 vTexCoord; +in vec3 vWorldPos; +in vec3 vWorldNormal; +in vec4 vClipPos; +out vec4 fragColor; + +// ── Gradient noise ─────────────────────────────────────────────────────────── +// Returns a value in [-1, 1] using smooth gradient noise (Perlin-like). + +vec2 _hash2(vec2 p) { + p = vec2(dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3))); + return -1.0 + 2.0 * fract(sin(p) * 43758.5453); +} + +float _noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); // Hermite smooth step + return mix( + mix(dot(_hash2(i), f), + dot(_hash2(i + vec2(1,0)), f - vec2(1,0)), u.x), + mix(dot(_hash2(i + vec2(0,1)), f - vec2(0,1)), + dot(_hash2(i + vec2(1,1)), f - vec2(1,1)), u.x), + u.y + ) * 0.5 + 0.5; // remap to [0,1] +} + +// ── Procedural wave normal ─────────────────────────────────────────────────── +// Samples noise at two UV layers, computes gradients, and returns a +// perturbed surface normal in world space. +vec3 _waveNormal(vec2 baseUV, float t) { + float scale = osl_WaveScale; + float spd = osl_WaveSpeed; + float rough = osl_Roughness; + + vec2 uv1 = baseUV * scale + vec2(t * spd * 0.024, t * spd * 0.016); + vec2 uv2 = baseUV * scale * 0.65 - vec2(t * spd * 0.019, t * spd * 0.028); + + const float eps = 0.025; + + // First wave layer gradient + float h00 = _noise(uv1); + float hdx = _noise(uv1 + vec2(eps, 0.0)); + float hdy = _noise(uv1 + vec2(0.0, eps)); + + // Second wave layer gradient + float h00b = _noise(uv2); + float hdxb = _noise(uv2 + vec2(eps, 0.0)); + float hdyb = _noise(uv2 + vec2(0.0, eps)); + + // Combined gradient (normalised by eps) + vec2 grad = vec2( + ((hdx - h00) + (hdxb - h00b)), + ((hdy - h00) + (hdyb - h00b)) + ) * (rough / eps); + + // Blend gradient with the geometry's up-normal + // We assume the water surface is approximately horizontal so vWorldNormal ≈ (0,0,1). + // The gradient perturbs the XZ plane (in Panda3D Y-up convention that is XY). + vec3 perturbed = normalize(vWorldNormal + vec3(-grad.x, -grad.y, 0.0)); + return perturbed; +} + +void main() { + float t = osl_Time; + + // ── Wave-perturbed normal ──────────────────────────────────────────── + vec3 N = _waveNormal(vTexCoord, t); + + // ── View direction ─────────────────────────────────────────────────── + vec3 V = normalize(osl_CameraPos - vWorldPos); + + // ── Fresnel (Schlick approximation) ────────────────────────────────── + // F0 for water-air interface ≈ 0.02 + float NdotV = max(0.0, dot(N, V)); + float f0 = 0.020; + float fresnel = f0 + (1.0 - f0) * pow(1.0 - NdotV, osl_FresnelPower); + fresnel = clamp(fresnel, 0.0, 1.0); + + // ── Planar reflection lookup (distorted by wave normal) ────────────── + vec2 screenUV = (vClipPos.xy / vClipPos.w) * 0.5 + 0.5; + // Distort reflection UV by the wave normal's XY deviation. + vec2 distort = (N.xy - vWorldNormal.xy) * 0.055; + vec2 reflUV = vec2(screenUV.x + distort.x, + 1.0 - screenUV.y + distort.y); + reflUV = clamp(reflUV, 0.001, 0.999); + vec4 reflColor = texture(osl_ReflectionTex, reflUV); + + // ── Deep-water / refraction colour ─────────────────────────────────── + // Modulate water base colour with a subtle depth-derived darkening. + // We approximate depth by projecting the fragment onto the vertical axis. + float depthFade = clamp(1.0 - abs(N.z - 0.9) * 6.0, 0.0, 1.0); + vec4 waterBase = osl_WaterColor * (0.80 + 0.20 * depthFade); + + // ── Blend refraction and reflection ────────────────────────────────── + vec4 surface = mix(waterBase, reflColor, fresnel * osl_ReflectionStrength); + + // ── Sun specular (Blinn-Phong glint) ───────────────────────────────── + // osl_SunDir points FROM the scene TOWARD the sun. + vec3 sunToward = normalize(osl_SunDir); + vec3 H = normalize(V + sunToward); + float NdotH = max(0.0, dot(N, H)); + // High shininess (128) = tight glint; spread by roughness. + float shininess = mix(256.0, 32.0, osl_Roughness); + float spec = pow(NdotH, shininess); + // Attenuate specular when sun is below the wave horizon. + float NdotL = max(0.0, dot(N, sunToward)); + spec *= NdotL; + vec3 specColor = osl_SunColor.rgb * spec * 0.55; + + // ── Edge foam (approximated by wave crest detection) ────────────────── + // High wave crests (large upward normal component) get a slight white tinge. + float crestFactor = smoothstep(0.80, 1.0, N.z); + vec3 foamColor = vec3(0.92, 0.96, 1.0); + surface.rgb = mix(surface.rgb, foamColor, crestFactor * 0.18); + + // ── Final composition ──────────────────────────────────────────────── + vec3 finalRGB = surface.rgb + specColor; + + // Soft alpha: more transparent near horizonal viewing angles (grazing). + float alpha = mix(0.72, 0.92, fresnel); + + fragColor = vec4(finalRGB, alpha); +} diff --git a/toontown/shaders/water.vert.glsl b/toontown/shaders/water.vert.glsl new file mode 100644 index 0000000..b32f359 --- /dev/null +++ b/toontown/shaders/water.vert.glsl @@ -0,0 +1,53 @@ +// Water surface vertex shader. +// Passes world-space position, surface normal, texture coords and clip-space +// position to the fragment stage. Gentle vertex-displacement is applied to +// break up the perfectly flat water surface and sell the wave motion. +// +// Uniforms (set by OutdoorLighting._setupWaterNode): +// osl_Time – seconds since scene start (drives wave animation) +// osl_WaveScale – UV tiling scale for procedural waves +// osl_WaveSpeed – wave animation speed multiplier +#version 130 + +uniform mat4 p3d_ModelViewProjectionMatrix; +uniform mat4 p3d_ModelMatrix; +uniform float osl_Time; +uniform float osl_WaveScale; +uniform float osl_WaveSpeed; + +in vec4 p3d_Vertex; +in vec3 p3d_Normal; +in vec2 p3d_MultiTexCoord0; + +out vec2 vTexCoord; +out vec3 vWorldPos; +out vec3 vWorldNormal; +out vec4 vClipPos; + +// Minimal cheap hash for vertex-level displacement (not the same as the +// higher-quality noise used in the fragment stage). +float vhash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +void main() { + float t = osl_Time * osl_WaveSpeed; + float scale = osl_WaveScale; + + // Two-layer vertex displacement along the surface normal. + // Kept intentionally small (~0.15 u max) so the geometry stays close + // to the water plane and shadow/reflection cameras are not confused. + vec2 uv = p3d_MultiTexCoord0 * scale; + float d1 = sin(uv.x * 6.28 + t * 1.1) * cos(uv.y * 4.71 + t * 0.9) * 0.08; + float d2 = sin(uv.x * 3.14 - t * 0.7) * sin(uv.y * 7.85 + t * 1.3) * 0.06; + float disp = d1 + d2; + + vec4 displaced = p3d_Vertex + vec4(p3d_Normal * disp, 0.0); + + vec4 worldPos4 = p3d_ModelMatrix * displaced; + vWorldPos = worldPos4.xyz; + vWorldNormal = normalize(mat3(p3d_ModelMatrix) * p3d_Normal); + vTexCoord = p3d_MultiTexCoord0; + vClipPos = p3d_ModelViewProjectionMatrix * displaced; + gl_Position = vClipPos; +}