graphics: add custom shaders (water, sky, sunrays, post-processing) and outdoor lighting

This commit is contained in:
Toontown Super 2026-07-13 14:08:43 -04:00
parent 04599db6d6
commit 33e326306a
16 changed files with 4644 additions and 31 deletions

View File

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

View File

@ -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()

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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,

View File

@ -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

View File

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

View File

@ -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')

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}