feat(render): Phase G.1/G.2 — SceneLighting UBO + sky renderer + shader integration
Wire the existing LightManager + WorldTimeService state into visible
rendering. Every draw call (terrain, static mesh, instanced mesh, sky)
now shares one SceneLighting UBO at binding=1 carrying:
- 8 Light slots (Directional / Point / Spot, retail hard-cutoff)
- Ambient RGB + active light count
- Fog start/end/mode + color + lightning flash scalar
- Camera world position + day fraction
The CPU side (SceneLightingUbo in Core.Lighting) is a POD struct that
gets BufferSubData'd once per frame from GameWindow.OnRender. Shaders
read the block via `layout(std140, binding = 1) uniform SceneLighting`
— no per-program uniform uploads.
Shader changes:
- mesh.frag + mesh_instanced.frag accumulate 8 dynamic lights per
fragment using the retail no-attenuation hard-cutoff model
(r13 §10.2 / §13.1). Sun reads slot 0; spots use hard cos-cone test.
Additive lightning flash + linear fog layered on top. Saturate
clamps per-channel to 1.0.
- terrain.vert bakes AdjustPlanes sun+ambient per vertex using the
retail MIN_FACTOR = 0.08 ambient floor (r13 §7). terrain.frag adds
fog + flash on top of the baked vertex color.
- mesh.vert + mesh_instanced.vert emit vWorldPos so the fragment
stage can do per-pixel lighting against world-space positions.
- New sky.vert / sky.frag pair — unlit, scroll-UV, camera-centered,
with its own 0.1..1e6 far plane. Ports WorldBuilder's skybox.
SkyRenderer (new file in App/Rendering/Sky/) ports WorldBuilder's
SkyboxRenderManager verbatim for the C# idiom: zeroed view translation,
dedicated projection, depth mask off, iterate each visible SkyObject
in the day group, apply arc transform (Z rot for heading + Y rot for
arc sweep), feed TexVelocityX/Y as a scrolling UV offset, apply
per-keyframe SkyObjectReplace overrides (mesh swap + transparency +
luminosity) for overcast / dusk cloud variants.
GameWindow integration:
- OnLoad parses Region (0x13000000) into LoadedSkyDesc and hot-swaps
WorldTime's provider to the dat-accurate keyframes. Seeds to noon
for offline rendering. Creates the SceneLightingUboBinding and the
SkyRenderer.
- OnRender: set clear color from atmosphere fog, tick WeatherSystem,
spawn/stop rain/snow camera-local emitters on kind change, feed
sun to LightManager (zero intensity indoors — r13 §13.7), tick
LightManager against viewer pos, build + upload the UBO, draw
sky before terrain, draw terrain + static + instanced using the
shared UBO.
5 new UBO packing tests (struct sizes, slot population, 8-light cap,
directional slot 0).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0df1c5b4a6
commit
9957070cab
15 changed files with 1255 additions and 91 deletions
|
|
@ -162,6 +162,23 @@ public sealed class GameWindow : IDisposable
|
|||
new AcDream.Core.World.WorldTimeService(
|
||||
AcDream.Core.World.SkyStateProvider.Default());
|
||||
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
|
||||
public readonly AcDream.Core.World.WeatherSystem Weather = new();
|
||||
|
||||
// Phase G.1 sky renderer + shared UBO. Created once the GL context
|
||||
// exists in OnLoad; shared across every other renderer via
|
||||
// binding = 1 so terrain/mesh/instanced/sky all read the same
|
||||
// sun / ambient / fog / flash data per frame.
|
||||
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
|
||||
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
|
||||
private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc;
|
||||
|
||||
// Current rain/snow emitter handles — spawned on weather-kind change
|
||||
// and stopped when the kind leaves Rain/Snow. Non-zero == active.
|
||||
private int _rainEmitterHandle;
|
||||
private int _snowEmitterHandle;
|
||||
private AcDream.Core.World.WeatherKind _lastWeatherKind =
|
||||
AcDream.Core.World.WeatherKind.Clear;
|
||||
private double _weatherAccum;
|
||||
|
||||
// Phase B.2: player movement mode.
|
||||
private AcDream.App.Input.PlayerMovementController? _playerController;
|
||||
|
|
@ -566,6 +583,12 @@ public sealed class GameWindow : IDisposable
|
|||
Path.Combine(shadersDir, "mesh_instanced.vert"),
|
||||
Path.Combine(shadersDir, "mesh_instanced.frag"));
|
||||
|
||||
// Phase G.1/G.2: shared scene-lighting UBO. Stays bound at
|
||||
// binding=1 for the lifetime of the process — every shader that
|
||||
// declares `layout(std140, binding = 1) uniform SceneLighting`
|
||||
// reads from this without further intervention.
|
||||
_sceneLightingUbo = new SceneLightingUboBinding(_gl);
|
||||
|
||||
_debugLines = new DebugLineRenderer(_gl, shadersDir);
|
||||
|
||||
// Debug HUD: load a system monospace font and set up the text overlay.
|
||||
|
|
@ -640,6 +663,35 @@ public sealed class GameWindow : IDisposable
|
|||
if (heightTable is null || heightTable.Length < 256)
|
||||
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
|
||||
|
||||
// Phase G.1: parse the full sky descriptor (day groups, keyframes,
|
||||
// celestial mesh layers) and swap WorldTime's provider over to the
|
||||
// dat-backed keyframes. The stub default provider is only used if
|
||||
// the Region lacks HasSkyInfo.
|
||||
if (region is not null)
|
||||
{
|
||||
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
|
||||
if (_loadedSkyDesc is not null)
|
||||
{
|
||||
WorldTime.SetProvider(_loadedSkyDesc.BuildDefaultProvider());
|
||||
WorldTime.TickSize = _loadedSkyDesc.TickSize > 0 ? _loadedSkyDesc.TickSize : 1.0;
|
||||
Console.WriteLine(
|
||||
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
|
||||
$"TickSize={_loadedSkyDesc.TickSize}, LightTickSize={_loadedSkyDesc.LightTickSize}");
|
||||
if (_loadedSkyDesc.DefaultDayGroup is not null)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"sky: default group '{_loadedSkyDesc.DefaultDayGroup.Name}' has " +
|
||||
$"{_loadedSkyDesc.DefaultDayGroup.SkyObjects.Count} sky objects, " +
|
||||
$"{_loadedSkyDesc.DefaultDayGroup.SkyTimes.Count} keyframes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed WorldTime to noon so outdoor scenes aren't pitch-black before
|
||||
// the server sends its first TimeSync packet (offline rendering in
|
||||
// particular never receives one).
|
||||
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks * 0.5);
|
||||
|
||||
// Build the terrain atlas once from the Region dat.
|
||||
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
|
||||
|
||||
|
|
@ -676,6 +728,15 @@ public sealed class GameWindow : IDisposable
|
|||
_textureCache = new TextureCache(_gl, _dats);
|
||||
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache);
|
||||
|
||||
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
|
||||
// with depth writes off + far plane 1e6 so celestial meshes
|
||||
// never clip. Shares the TextureCache with the static pipeline.
|
||||
var skyShader = new Shader(_gl,
|
||||
Path.Combine(shadersDir, "sky.vert"),
|
||||
Path.Combine(shadersDir, "sky.frag"));
|
||||
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
|
||||
_gl, _dats, skyShader, _textureCache);
|
||||
|
||||
// Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
|
||||
// Parse runtime radius from environment (default 2 → 5×5 window).
|
||||
// Values outside [0, 8] fall back to the field default of 2.
|
||||
|
|
@ -2863,6 +2924,16 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnRender(double deltaSeconds)
|
||||
{
|
||||
// Phase G.1: set the clear color from the current sky's fog
|
||||
// tint so the horizon band continues naturally past the
|
||||
// rendered geometry. Fog blends to this color at max distance
|
||||
// so there's no visible seam. Updated each frame from the
|
||||
// interpolated keyframe.
|
||||
var kf = WorldTime.CurrentSky;
|
||||
var atmo = Weather.Snapshot(in kf);
|
||||
var fogColor = atmo.FogColor;
|
||||
_gl!.ClearColor(fogColor.X, fogColor.Y, fogColor.Z, 1f);
|
||||
|
||||
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
|
||||
// Phase 6.4: advance per-entity animation playback before drawing
|
||||
|
|
@ -2870,6 +2941,21 @@ public sealed class GameWindow : IDisposable
|
|||
if (_animatedEntities.Count > 0)
|
||||
TickAnimations((float)deltaSeconds);
|
||||
|
||||
// Phase G.1: weather state machine — deterministic per-day roll
|
||||
// + transitions + lightning flash.
|
||||
var cal = WorldTime.CurrentCalendar;
|
||||
int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth *
|
||||
AcDream.Core.World.DerethDateTime.MonthsInAYear)
|
||||
+ (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth
|
||||
+ (cal.Day - 1);
|
||||
Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds);
|
||||
_weatherAccum += deltaSeconds;
|
||||
|
||||
// Update the rain/snow particle emitters when the weather kind
|
||||
// changes. Keep the emitters fed by the ParticleSystem tick so
|
||||
// visuals stay alive frame-over-frame.
|
||||
UpdateWeatherParticles(atmo);
|
||||
|
||||
// Phase E.3: advance live particle emitters AFTER animation tick
|
||||
// so emitters spawned by hooks fired this frame get integrated.
|
||||
_particleSystem?.Tick((float)deltaSeconds);
|
||||
|
|
@ -2882,26 +2968,14 @@ public sealed class GameWindow : IDisposable
|
|||
var camera = _cameraController.Active;
|
||||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
|
||||
|
||||
// Never cull the landblock the player is currently on.
|
||||
uint? playerLb = null;
|
||||
if (_playerMode && _playerController is not null)
|
||||
{
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
|
||||
// Step 4: portal visibility — determine which interior cells to render.
|
||||
// Extract camera world position from the inverse of the view matrix.
|
||||
// Extract camera world position from the inverse of the view
|
||||
// matrix — needed by the scene-lighting UBO (for fog distance)
|
||||
// and by the sky renderer (for the camera-centered sky dome).
|
||||
System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
|
||||
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
|
||||
|
||||
// Phase E.2 audio: update listener pose so 3D sounds pan/attenuate
|
||||
// correctly relative to where we're looking. Fwd = -Z of the view
|
||||
// matrix (OpenGL convention), up = +Y. Both live in the inverse
|
||||
// view matrix's basis vectors.
|
||||
// correctly relative to where we're looking.
|
||||
if (_audioEngine is not null && _audioEngine.IsAvailable)
|
||||
{
|
||||
var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
|
||||
|
|
@ -2912,9 +2986,44 @@ public sealed class GameWindow : IDisposable
|
|||
up.X, up.Y, up.Z);
|
||||
}
|
||||
|
||||
// Step 4: portal visibility — compute BEFORE the UBO upload so
|
||||
// the indoor flag drives the sun's intensity to zero for
|
||||
// dungeons (r13 §13.7).
|
||||
var visibility = _cellVisibility.ComputeVisibility(camPos);
|
||||
bool cameraInsideCell = visibility?.CameraCell is not null;
|
||||
|
||||
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
|
||||
// the scene-lighting UBO once per frame. Every shader that
|
||||
// consumes binding=1 reads the same data for the rest of the
|
||||
// frame — terrain, static mesh, instanced mesh, sky.
|
||||
UpdateSunFromSky(kf, cameraInsideCell);
|
||||
Lighting.Tick(camPos);
|
||||
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
|
||||
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
|
||||
_sceneLightingUbo?.Upload(ubo);
|
||||
|
||||
// Never cull the landblock the player is currently on.
|
||||
uint? playerLb = null;
|
||||
if (_playerMode && _playerController is not null)
|
||||
{
|
||||
var pp = _playerController.Position;
|
||||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||||
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||||
}
|
||||
|
||||
// Phase G.1: sky renderer — draws the far-plane-infinity
|
||||
// celestial meshes FIRST so the rest of the scene z-tests
|
||||
// on top of them (depth mask off, no depth writes). Skipped
|
||||
// when indoors; dungeons fully block sky visibility.
|
||||
if (!cameraInsideCell)
|
||||
{
|
||||
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction,
|
||||
_loadedSkyDesc?.DefaultDayGroup);
|
||||
}
|
||||
|
||||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||||
|
||||
// Conditional depth clear: when camera is inside a building, clear
|
||||
// depth (not color) so interior geometry writes fresh Z values on top
|
||||
// of the terrain color buffer. Exit portals show outdoor terrain color
|
||||
|
|
@ -3409,6 +3518,163 @@ public sealed class GameWindow : IDisposable
|
|||
ae.CurrFrame = ae.LowFrame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive the current sun (directional light, slot 0 of the UBO)
|
||||
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
|
||||
/// plus the cell ambient. Indoor cells force the sun intensity to
|
||||
/// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient.
|
||||
/// </summary>
|
||||
private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell)
|
||||
{
|
||||
// Sun direction: points FROM the sun TOWARDS the world. Our
|
||||
// shader does dot(N, -forward) so a positive N·L means the
|
||||
// surface faces the sun.
|
||||
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
|
||||
|
||||
if (cameraInsideCell)
|
||||
{
|
||||
// Dungeon default per r13 §3 — warm-dark ambient, no sun.
|
||||
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
||||
{
|
||||
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
||||
WorldForward = sunToWorld,
|
||||
ColorLinear = System.Numerics.Vector3.Zero,
|
||||
Intensity = 0f,
|
||||
Range = 1f,
|
||||
};
|
||||
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
|
||||
AmbientColor: new System.Numerics.Vector3(0.10f, 0.09f, 0.08f),
|
||||
SunColor: System.Numerics.Vector3.Zero,
|
||||
SunDirection: sunToWorld);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Outdoor: full keyframe sun + ambient; colors are already
|
||||
// pre-multiplied by DirBright / AmbBright inside
|
||||
// SkyDescLoader so we feed them straight into the UBO.
|
||||
Lighting.Sun = new AcDream.Core.Lighting.LightSource
|
||||
{
|
||||
Kind = AcDream.Core.Lighting.LightKind.Directional,
|
||||
WorldForward = sunToWorld,
|
||||
ColorLinear = kf.SunColor,
|
||||
Intensity = 1f,
|
||||
Range = 1f,
|
||||
};
|
||||
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
|
||||
AmbientColor: kf.AmbientColor,
|
||||
SunColor: kf.SunColor,
|
||||
SunDirection: sunToWorld);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keep the rain/snow camera-anchored emitters aligned with the
|
||||
/// current weather state. Spawns on entry, stops on exit, with no
|
||||
/// per-frame churn while the state is stable. Emitters are camera-
|
||||
/// local (<see cref="AcDream.Core.Vfx.EmitterFlags.AttachLocal"/>)
|
||||
/// so walking never leaves the rain volume (r12 §7).
|
||||
/// </summary>
|
||||
private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo)
|
||||
{
|
||||
if (_particleSystem is null) return;
|
||||
|
||||
if (atmo.Kind == _lastWeatherKind) return; // no change
|
||||
|
||||
// Stop any existing emitters first.
|
||||
if (_rainEmitterHandle != 0)
|
||||
{
|
||||
_particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true);
|
||||
_rainEmitterHandle = 0;
|
||||
}
|
||||
if (_snowEmitterHandle != 0)
|
||||
{
|
||||
_particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true);
|
||||
_snowEmitterHandle = 0;
|
||||
}
|
||||
|
||||
// Anchor at camera world position; AttachLocal keeps it moving.
|
||||
var anchor = System.Numerics.Vector3.Zero;
|
||||
if (_cameraController is not null)
|
||||
{
|
||||
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv);
|
||||
anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
|
||||
}
|
||||
|
||||
switch (atmo.Kind)
|
||||
{
|
||||
case AcDream.Core.World.WeatherKind.Rain:
|
||||
case AcDream.Core.World.WeatherKind.Storm:
|
||||
_rainEmitterHandle = _particleSystem.SpawnEmitter(
|
||||
BuildRainDesc(), anchor);
|
||||
break;
|
||||
case AcDream.Core.World.WeatherKind.Snow:
|
||||
_snowEmitterHandle = _particleSystem.SpawnEmitter(
|
||||
BuildSnowDesc(), anchor);
|
||||
break;
|
||||
}
|
||||
|
||||
_lastWeatherKind = atmo.Kind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with
|
||||
/// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so
|
||||
/// drops cover the ~60m fall at terminal velocity.
|
||||
/// </summary>
|
||||
private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new()
|
||||
{
|
||||
DatId = 0xFFFF_0001u, // synthetic id
|
||||
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
|
||||
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
|
||||
AcDream.Core.Vfx.EmitterFlags.Billboard,
|
||||
EmitRate = 500f,
|
||||
MaxParticles = 2000,
|
||||
LifetimeMin = 1.0f,
|
||||
LifetimeMax = 1.4f,
|
||||
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
|
||||
MinOffset = 0f,
|
||||
MaxOffset = 50f,
|
||||
SpawnDiskRadius = 15f,
|
||||
InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f),
|
||||
VelocityJitter = 2f,
|
||||
Gravity = System.Numerics.Vector3.Zero,
|
||||
StartColorArgb = 0x40B0C0E0u,
|
||||
EndColorArgb = 0x20B0C0E0u,
|
||||
StartAlpha = 0.3f,
|
||||
EndAlpha = 0f,
|
||||
StartSize = 0.05f,
|
||||
EndSize = 0.05f,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling
|
||||
/// sideways drift, small billboards, 100 flakes/sec, long lifespan.
|
||||
/// </summary>
|
||||
private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new()
|
||||
{
|
||||
DatId = 0xFFFF_0002u,
|
||||
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
|
||||
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
|
||||
AcDream.Core.Vfx.EmitterFlags.Billboard,
|
||||
EmitRate = 100f,
|
||||
MaxParticles = 1000,
|
||||
LifetimeMin = 4f,
|
||||
LifetimeMax = 8f,
|
||||
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
|
||||
MinOffset = 0f,
|
||||
MaxOffset = 30f,
|
||||
SpawnDiskRadius = 15f,
|
||||
InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f),
|
||||
VelocityJitter = 0.8f,
|
||||
Gravity = System.Numerics.Vector3.Zero,
|
||||
StartColorArgb = 0xE0FFFFFFu,
|
||||
EndColorArgb = 0x80FFFFFFu,
|
||||
StartAlpha = 0.85f,
|
||||
EndAlpha = 0.3f,
|
||||
StartSize = 0.08f,
|
||||
EndSize = 0.06f,
|
||||
};
|
||||
|
||||
private void OnClosing()
|
||||
{
|
||||
// Phase A.1: join the streamer worker thread before tearing down GL
|
||||
|
|
@ -3422,6 +3688,8 @@ public sealed class GameWindow : IDisposable
|
|||
_meshShader?.Dispose();
|
||||
_terrain?.Dispose();
|
||||
_shader?.Dispose();
|
||||
_sceneLightingUbo?.Dispose();
|
||||
_skyRenderer?.Dispose();
|
||||
_debugLines?.Dispose();
|
||||
_textRenderer?.Dispose();
|
||||
_debugFont?.Dispose();
|
||||
|
|
|
|||
|
|
@ -159,10 +159,10 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable
|
|||
var vp = camera.View * camera.Projection;
|
||||
_shader.SetMatrix4("uViewProjection", vp);
|
||||
|
||||
// Lighting uniforms matching ACME StaticObject.vert.
|
||||
var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f));
|
||||
_shader.SetVec3("uLightDirection", lightDir);
|
||||
_shader.SetFloat("uAmbientIntensity", 0.45f);
|
||||
// Phase G: lighting + ambient + fog are owned by the
|
||||
// SceneLighting UBO (binding=1) uploaded once per frame by
|
||||
// GameWindow. The instanced mesh fragment shader reads it
|
||||
// directly — no per-draw uniform uploads needed.
|
||||
|
||||
// ── Collect and group instances ───────────────────────────────────────
|
||||
CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds);
|
||||
|
|
|
|||
67
src/AcDream.App/Rendering/SceneLightingUboBinding.cs
Normal file
67
src/AcDream.App/Rendering/SceneLightingUboBinding.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.Core.Lighting;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// GL wrapper that owns the SceneLighting UBO buffer, updates its
|
||||
/// contents each frame, and keeps it bound at binding=1 so every
|
||||
/// shader sampling <c>uLights[]</c> / <c>uFogColor</c> / etc reads
|
||||
/// consistent data without per-shader re-upload.
|
||||
///
|
||||
/// <para>
|
||||
/// Usage (r12 §13.2 + r13 §12.3):
|
||||
/// <list type="number">
|
||||
/// <item><description>Instantiate once at startup, after the GL context exists.</description></item>
|
||||
/// <item><description>Each frame, after <see cref="LightManager.Tick"/>, call <see cref="Upload"/> with a freshly-built <see cref="SceneLightingUbo"/>.</description></item>
|
||||
/// <item><description>Shader programs that declare <c>layout(std140, binding = 1) uniform SceneLighting { ... }</c> automatically pick up the data.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed unsafe class SceneLightingUboBinding : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly uint _ubo;
|
||||
private bool _disposed;
|
||||
|
||||
public SceneLightingUboBinding(GL gl)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_ubo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
|
||||
// Pre-allocate with the final size; BufferSubData each frame.
|
||||
_gl.BufferData(
|
||||
BufferTargetARB.UniformBuffer,
|
||||
(nuint)SceneLightingUbo.SizeInBytes,
|
||||
(void*)0,
|
||||
BufferUsageARB.DynamicDraw);
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
|
||||
|
||||
// Bind the buffer to the chosen binding point exactly once — shaders
|
||||
// that declare this binding in their layout block will read from it
|
||||
// on every draw without further intervention.
|
||||
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
|
||||
SceneLightingUbo.BindingPoint, _ubo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Push the current frame's UBO contents to the GPU. Cheap (576 bytes)
|
||||
/// so fine to call unconditionally every frame.
|
||||
/// </summary>
|
||||
public void Upload(SceneLightingUbo data)
|
||||
{
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, _ubo);
|
||||
_gl.BufferSubData(BufferTargetARB.UniformBuffer,
|
||||
(nint)0, (nuint)SceneLightingUbo.SizeInBytes, &data);
|
||||
_gl.BindBuffer(BufferTargetARB.UniformBuffer, 0);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_gl.DeleteBuffer(_ubo);
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#version 430 core
|
||||
in vec2 vTex;
|
||||
in vec3 vWorldNormal;
|
||||
in vec3 vWorldPos;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2D uDiffuse;
|
||||
|
|
@ -11,35 +12,114 @@ uniform sampler2D uDiffuse;
|
|||
// 2 = AlphaBlend — GL blending handles compositing; do NOT discard
|
||||
// 3 = Additive — GL additive blending; do NOT discard
|
||||
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
|
||||
//
|
||||
// Only ClipMap uses the alpha-discard path. AlphaBlend/Additive/InvAlpha
|
||||
// rely entirely on the GL blend stage — discarding low-alpha fragments
|
||||
// would make semi-transparent surfaces (portals, glows) fully invisible.
|
||||
uniform int uTranslucencyKind;
|
||||
|
||||
// Phase 3a: simple directional lighting. A single sun direction + ambient term
|
||||
// gives scenery and building faces enough differentiation to read as 3D instead
|
||||
// of looking like paper cutouts. Hardcoded for now; a later phase can route
|
||||
// light parameters through uniforms driven by the game's time-of-day.
|
||||
// Sun direction tuned after Phase 3a verification: (0.4,0.3,0.8) was too
|
||||
// vertical — roofs and ground both landed near peak brightness and only
|
||||
// walls dropped, so the contrast was hard to read through textures. More
|
||||
// oblique + lower ambient + higher diffuse = contrast ratio ~3.3x.
|
||||
const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6));
|
||||
const float AMBIENT = 0.25;
|
||||
const float DIFFUSE = 0.75;
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Phase G.1+G.2: shared scene-lighting UBO (binding = 1).
|
||||
//
|
||||
// Layout mirrors SceneLightingUbo in C#:
|
||||
// struct Light {
|
||||
// vec4 posAndKind; xyz = world pos, w = kind (0=dir,1=point,2=spot)
|
||||
// vec4 dirAndRange; xyz = forward, w = range (metres, hard cutoff)
|
||||
// vec4 colorAndIntensity; xyz = RGB linear, w = intensity
|
||||
// vec4 coneAngleEtc; x = cone (rad), yzw = reserved
|
||||
// };
|
||||
// layout(std140, binding = 1) uniform SceneLighting {
|
||||
// Light uLights[8];
|
||||
// vec4 uCellAmbient; xyz = ambient RGB, w = active count
|
||||
// vec4 uFogParams; x = start, y = end, z = flash, w = mode
|
||||
// vec4 uFogColor; xyz = color
|
||||
// vec4 uCameraAndTime; xyz = camera pos, w = day fraction
|
||||
// };
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// Retail hard-cutoff lighting equation (r13 §10.2). No distance
|
||||
// attenuation inside Range; hard edge at Range; spotlights use a
|
||||
// binary cos-cone test. This is deliberate — the retail "bubble of
|
||||
// light" look relies on crisp boundaries.
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int active = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= active) break;
|
||||
|
||||
int kind = int(uLights[i].posAndKind.w);
|
||||
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
|
||||
|
||||
if (kind == 0) {
|
||||
// Directional: "forward" is the light's direction vector
|
||||
// pointing INTO the scene. N·(-forward) = light-facing.
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += Lcol * ndl;
|
||||
} else {
|
||||
// Point / spot: falloff is a HARD bubble at Range.
|
||||
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
|
||||
float d = length(toL);
|
||||
float range = uLights[i].dirAndRange.w;
|
||||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
float atten = 1.0; // retail: no attenuation inside Range
|
||||
if (kind == 2) {
|
||||
// Spotlight: hard-edged cos-cone test.
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||
}
|
||||
lit += Lcol * ndl * atten;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lit;
|
||||
}
|
||||
|
||||
// Linear fog (r12 §5.1): mode 1 = LINEAR, 0 = off, others reserved.
|
||||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||
int mode = int(uFogParams.w);
|
||||
if (mode == 0) return lit;
|
||||
float d = length(worldPos - uCameraAndTime.xyz);
|
||||
float fogStart = uFogParams.x;
|
||||
float fogEnd = uFogParams.y;
|
||||
float span = max(1e-3, fogEnd - fogStart);
|
||||
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
|
||||
return mix(lit, uFogColor.xyz, fog);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 sampled = texture(uDiffuse, vTex);
|
||||
|
||||
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
|
||||
// Blended surface types (AlphaBlend, Additive, InvAlpha) must NOT
|
||||
// discard here — that would make every semi-transparent pixel invisible
|
||||
// before the blend stage even runs.
|
||||
if (uTranslucencyKind == 1 && sampled.a < 0.5) discard;
|
||||
|
||||
vec3 N = normalize(vWorldNormal);
|
||||
float ndotl = max(dot(N, SUN_DIR), 0.0);
|
||||
float lighting = AMBIENT + DIFFUSE * ndotl;
|
||||
fragColor = vec4(sampled.rgb * lighting, sampled.a);
|
||||
vec3 lit = accumulateLights(N, vWorldPos);
|
||||
|
||||
// Lightning flash (r12 §9) — additive cold-white pulse layered on top
|
||||
// of diffuse lighting.
|
||||
float flash = uFogParams.z;
|
||||
lit += flash * vec3(0.6, 0.6, 0.75);
|
||||
|
||||
// Clamp per-channel to 1.0 — matches retail (r13 §13.1).
|
||||
lit = min(lit, vec3(1.0));
|
||||
|
||||
vec3 rgb = sampled.rgb * lit;
|
||||
|
||||
// Atmospheric fog — applied after lighting.
|
||||
rgb = applyFog(rgb, vWorldPos);
|
||||
|
||||
fragColor = vec4(rgb, sampled.a);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ uniform mat4 uProjection;
|
|||
|
||||
out vec2 vTex;
|
||||
out vec3 vWorldNormal;
|
||||
out vec3 vWorldPos;
|
||||
|
||||
void main() {
|
||||
vTex = aTex;
|
||||
|
|
@ -17,5 +18,7 @@ void main() {
|
|||
// scale would require the inverse transpose; we accept that as a Phase 3+
|
||||
// concern.
|
||||
vWorldNormal = normalize(mat3(uModel) * aNormal);
|
||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
||||
vec4 world = uModel * vec4(aPos, 1.0);
|
||||
vWorldPos = world.xyz;
|
||||
gl_Position = uProjection * uView * world;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
in vec2 vTex;
|
||||
in vec3 vWorldNormal;
|
||||
in float vLightingFactor;
|
||||
in vec3 vWorldPos;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
|
|
@ -18,14 +18,81 @@ uniform sampler2D uDiffuse;
|
|||
// 4 = InvAlpha — GL inverted-alpha blending; do NOT discard
|
||||
uniform int uTranslucencyKind;
|
||||
|
||||
// Phase G.1+G.2: shared scene-lighting UBO (see mesh.frag for layout docs).
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
vec3 accumulateLights(vec3 N, vec3 worldPos) {
|
||||
vec3 lit = uCellAmbient.xyz;
|
||||
int active = int(uCellAmbient.w);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
if (i >= active) break;
|
||||
|
||||
int kind = int(uLights[i].posAndKind.w);
|
||||
vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w;
|
||||
|
||||
if (kind == 0) {
|
||||
vec3 Ldir = -uLights[i].dirAndRange.xyz;
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
lit += Lcol * ndl;
|
||||
} else {
|
||||
vec3 toL = uLights[i].posAndKind.xyz - worldPos;
|
||||
float d = length(toL);
|
||||
float range = uLights[i].dirAndRange.w;
|
||||
if (d < range && range > 1e-3) {
|
||||
vec3 Ldir = toL / max(d, 1e-4);
|
||||
float ndl = max(0.0, dot(N, Ldir));
|
||||
float atten = 1.0;
|
||||
if (kind == 2) {
|
||||
float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5);
|
||||
float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz);
|
||||
atten *= (cos_l > cos_edge) ? 1.0 : 0.0;
|
||||
}
|
||||
lit += Lcol * ndl * atten;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lit;
|
||||
}
|
||||
|
||||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||
int mode = int(uFogParams.w);
|
||||
if (mode == 0) return lit;
|
||||
float d = length(worldPos - uCameraAndTime.xyz);
|
||||
float fogStart = uFogParams.x;
|
||||
float fogEnd = uFogParams.y;
|
||||
float span = max(1e-3, fogEnd - fogStart);
|
||||
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
|
||||
return mix(lit, uFogColor.xyz, fog);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 color = texture(uDiffuse, vTex);
|
||||
|
||||
// Alpha cutout only for clip-map surfaces (doors, windows, vegetation).
|
||||
// Blended surface types must NOT discard here — that kills every
|
||||
// semi-transparent pixel before the blend stage runs.
|
||||
if (uTranslucencyKind == 1 && color.a < 0.5) discard;
|
||||
|
||||
// Apply pre-computed Lambert + ambient lighting factor from the vertex shader.
|
||||
fragColor = vec4(color.rgb * vLightingFactor, color.a);
|
||||
vec3 N = normalize(vWorldNormal);
|
||||
vec3 lit = accumulateLights(N, vWorldPos);
|
||||
|
||||
// Lightning flash — additive scene bump.
|
||||
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
|
||||
|
||||
// Retail clamp per-channel to 1.0 (r13 §13.1).
|
||||
lit = min(lit, vec3(1.0));
|
||||
|
||||
vec3 rgb = color.rgb * lit;
|
||||
rgb = applyFog(rgb, vWorldPos);
|
||||
fragColor = vec4(rgb, color.a);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,10 @@ layout(location = 5) in vec4 aInstanceRow2;
|
|||
layout(location = 6) in vec4 aInstanceRow3;
|
||||
|
||||
uniform mat4 uViewProjection;
|
||||
uniform vec3 uLightDirection; // world-space light direction (points FROM sun, matching ACME)
|
||||
uniform float uAmbientIntensity;
|
||||
|
||||
out vec2 vTex;
|
||||
out vec3 vWorldNormal;
|
||||
out float vLightingFactor;
|
||||
out vec3 vWorldPos;
|
||||
|
||||
void main() {
|
||||
// Reconstruct the per-instance model matrix from its four row vectors.
|
||||
|
|
@ -30,11 +28,8 @@ void main() {
|
|||
vec4 worldPos = model * vec4(aPosition, 1.0);
|
||||
gl_Position = uViewProjection * worldPos;
|
||||
|
||||
vWorldPos = worldPos.xyz;
|
||||
// Transform normal into world space.
|
||||
vWorldNormal = normalize(mat3(model) * aNormal);
|
||||
vTex = aTexCoord;
|
||||
|
||||
// Lambert + ambient matching ACME StaticObject.vert:
|
||||
// LightingFactor = max(dot(Normal, -uLightDirection), 0.0) + uAmbientIntensity;
|
||||
vLightingFactor = max(dot(vWorldNormal, -uLightDirection), 0.0) + uAmbientIntensity;
|
||||
}
|
||||
|
|
|
|||
51
src/AcDream.App/Rendering/Shaders/sky.frag
Normal file
51
src/AcDream.App/Rendering/Shaders/sky.frag
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#version 430 core
|
||||
// Sky mesh fragment shader — sample the object's diffuse texture with
|
||||
// the scrolled UVs from the vertex stage. Unlit: sky meshes ARE the
|
||||
// gradient (r12 §2.2), not a surface lit by the sun.
|
||||
//
|
||||
// The per-keyframe replace override can dim the mesh (Transparent) or
|
||||
// brighten it (Luminosity); those two floats arrive as uTransparency /
|
||||
// uLuminosity uniforms.
|
||||
|
||||
in vec2 vTex;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2D uDiffuse;
|
||||
uniform float uTransparency; // 0 = fully visible, 1 = invisible
|
||||
uniform float uLuminosity; // 1 = normal, >1 makes the mesh glow
|
||||
uniform vec4 uTint; // per-object color tint (default white)
|
||||
|
||||
// Shared SceneLighting UBO — we only need the fog parameters to let the
|
||||
// horizon band of the sky blend smoothly into the scene's fog color at
|
||||
// the far edge, and the lightning flash to give storms their signature
|
||||
// strobe.
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
void main() {
|
||||
vec4 sampled = texture(uDiffuse, vTex);
|
||||
|
||||
// Apply tint + luminosity. Retail's SkyObjReplace.Luminosity can push
|
||||
// above 1 to make the sun mesh brighter than its texture; r12 §2.3.
|
||||
vec3 rgb = sampled.rgb * uTint.rgb * uLuminosity;
|
||||
|
||||
// Lightning additive bump — makes the sky itself flash during storms.
|
||||
rgb += uFogParams.z * vec3(0.5, 0.5, 0.55);
|
||||
|
||||
rgb = min(rgb, vec3(1.2)); // soft clamp to let luminosity over-bright mildly
|
||||
|
||||
float a = sampled.a * (1.0 - uTransparency) * uTint.a;
|
||||
if (a < 0.01) discard;
|
||||
fragColor = vec4(rgb, a);
|
||||
}
|
||||
22
src/AcDream.App/Rendering/Shaders/sky.vert
Normal file
22
src/AcDream.App/Rendering/Shaders/sky.vert
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#version 430 core
|
||||
// Sky mesh vertex shader — each celestial object is a GfxObj mesh
|
||||
// (sun billboard, cloud sheet, moon, star dome) rendered at large
|
||||
// distance with depth writes disabled. The view matrix has its
|
||||
// translation zeroed so the sky stays camera-centered; the projection
|
||||
// matrix has a huge far plane so 1e6-metre-away sky meshes never clip.
|
||||
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aNormal;
|
||||
layout(location = 2) in vec2 aTex;
|
||||
|
||||
uniform mat4 uModel; // per-object arc transform (r12 §2.1)
|
||||
uniform mat4 uSkyView; // camera view with M41..M43 = 0
|
||||
uniform mat4 uSkyProjection; // near=0.1, far=1e6
|
||||
uniform vec2 uUvScroll; // cumulative TexVelocityX/Y * time
|
||||
|
||||
out vec2 vTex;
|
||||
|
||||
void main() {
|
||||
vTex = aTex + uUvScroll;
|
||||
gl_Position = uSkyProjection * uSkyView * uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
#version 430 core
|
||||
// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
|
||||
// Landscape.frag, trimmed of editor-specific features (grid, brush,
|
||||
// walkable-slope highlighting) and with Phase 3a/3b directional lighting
|
||||
// layered on at the end.
|
||||
// walkable-slope highlighting). Phase G extends this with the shared
|
||||
// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog
|
||||
// + lightning flash.
|
||||
|
||||
in vec2 vBaseUV;
|
||||
in vec3 vWorldNormal;
|
||||
in float vLightingFactor;
|
||||
in vec3 vWorldPos;
|
||||
in vec3 vLightingRGB;
|
||||
in vec4 vOverlay0;
|
||||
in vec4 vOverlay1;
|
||||
in vec4 vOverlay2;
|
||||
|
|
@ -18,24 +20,34 @@ out vec4 fragColor;
|
|||
|
||||
uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
|
||||
uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture
|
||||
uniform float xAmbient; // ambient intensity (matching ACME Landscape.frag)
|
||||
|
||||
// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex
|
||||
// AdjustPlanes bake already incorporated sun + ambient.
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
// Per-texture tiling repeat count across a cell. WorldBuilder uses
|
||||
// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per
|
||||
// cell, 8 tiles across a landblock) until we wire the array. The previous
|
||||
// Phase 2b/3 single-layer path tiled at ~2 per cell, so the world may read
|
||||
// slightly coarser at 1.0 — tunable here if it looks wrong.
|
||||
// cell, 8 tiles across a landblock).
|
||||
const float TILE = 1.0;
|
||||
|
||||
// Three-layer alpha-weighted composite. Each terrain overlay layer
|
||||
// contributes based on its own alpha mask; missing layers (h == 0) collapse
|
||||
// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag.
|
||||
// Three-layer alpha-weighted composite.
|
||||
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
|
||||
float a0 = h0 == 0.0 ? 1.0 : t0.a;
|
||||
float a1 = h1 == 0.0 ? 1.0 : t1.a;
|
||||
float a2 = h2 == 0.0 ? 1.0 : t2.a;
|
||||
float aR = 1.0 - (a0 * a1 * a2);
|
||||
// avoid divide-by-zero when all three overlays are absent
|
||||
float aRsafe = max(aR, 1e-6);
|
||||
a0 = 1.0 - a0;
|
||||
a1 = 1.0 - a1;
|
||||
|
|
@ -82,7 +94,6 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
|
|||
result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z));
|
||||
if (pRoad0.w >= 0.0) {
|
||||
vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w));
|
||||
// Roads use inverted alpha (the mask stores NON-road coverage).
|
||||
result.a = 1.0 - a0.a;
|
||||
if (h1 > 0.0 && pRoad1.w >= 0.0) {
|
||||
vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w));
|
||||
|
|
@ -93,9 +104,18 @@ vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) {
|
|||
return result;
|
||||
}
|
||||
|
||||
vec3 applyFog(vec3 lit, vec3 worldPos) {
|
||||
int mode = int(uFogParams.w);
|
||||
if (mode == 0) return lit;
|
||||
float d = length(worldPos - uCameraAndTime.xyz);
|
||||
float fogStart = uFogParams.x;
|
||||
float fogEnd = uFogParams.y;
|
||||
float span = max(1e-3, fogEnd - fogStart);
|
||||
float fog = clamp((d - fogStart) / span, 0.0, 1.0);
|
||||
return mix(lit, uFogColor.xyz, fog);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Base color: if there's no base layer (sentinel -1) just render black
|
||||
// (shouldn't happen in valid data).
|
||||
vec4 baseColor = vec4(0.0);
|
||||
if (vBaseTexIdx >= 0.0) {
|
||||
baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx));
|
||||
|
|
@ -115,9 +135,15 @@ void main() {
|
|||
vec3 roadMasked = roads.rgb * roads.a;
|
||||
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
|
||||
|
||||
// Lighting matching ACME Landscape.frag:
|
||||
// litColor = finalColor * (saturate(vLightingFactor) + xAmbient);
|
||||
vec3 litColor = rgb * (clamp(vLightingFactor, 0.0, 1.0) + xAmbient);
|
||||
// Apply the per-vertex baked sun+ambient.
|
||||
vec3 lit = rgb * min(vLightingRGB, vec3(1.0));
|
||||
|
||||
fragColor = vec4(litColor, 1.0);
|
||||
// Lightning flash — additive.
|
||||
float flash = uFogParams.z;
|
||||
lit += flash * vec3(0.6, 0.6, 0.75);
|
||||
|
||||
// Atmospheric fog.
|
||||
lit = applyFog(lit, vWorldPos);
|
||||
|
||||
fragColor = vec4(lit, 1.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,28 @@ layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see bel
|
|||
|
||||
uniform mat4 uView;
|
||||
uniform mat4 uProjection;
|
||||
uniform vec3 xLightDirection; // world-space sun direction (matching ACME Landscape.vert)
|
||||
|
||||
// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun
|
||||
// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog
|
||||
// fields are consumed by the fragment stage.
|
||||
struct Light {
|
||||
vec4 posAndKind;
|
||||
vec4 dirAndRange;
|
||||
vec4 colorAndIntensity;
|
||||
vec4 coneAngleEtc;
|
||||
};
|
||||
layout(std140, binding = 1) uniform SceneLighting {
|
||||
Light uLights[8];
|
||||
vec4 uCellAmbient;
|
||||
vec4 uFogParams;
|
||||
vec4 uFogColor;
|
||||
vec4 uCameraAndTime;
|
||||
};
|
||||
|
||||
out vec2 vBaseUV;
|
||||
out vec3 vWorldNormal;
|
||||
out float vLightingFactor;
|
||||
out vec3 vWorldPos;
|
||||
out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake
|
||||
// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w".
|
||||
// Negative .z means "layer not present, skip it in the fragment shader."
|
||||
out vec4 vOverlay0;
|
||||
|
|
@ -22,6 +39,11 @@ out vec4 vRoad0;
|
|||
out vec4 vRoad1;
|
||||
flat out float vBaseTexIdx;
|
||||
|
||||
// Retail's "ambient floor" constant from the decompiled AdjustPlanes
|
||||
// path (r13 §7, DAT_00796344). Even a back-lit vertex sees at least
|
||||
// this fraction of the sun color — NOT additive with ambient.
|
||||
const float MIN_FACTOR = 0.08;
|
||||
|
||||
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
|
||||
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
|
||||
// 90° rotation count.
|
||||
|
|
@ -56,13 +78,6 @@ void main() {
|
|||
// gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a
|
||||
// specific order for each split direction; the table below must stay
|
||||
// in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches.
|
||||
//
|
||||
// Corner labels: 0=BL (low x/y), 1=BR (high x, low y),
|
||||
// 2=TR (high x/y), 3=TL (low x, high y).
|
||||
// WorldBuilder assigns cell-local UV per corner:
|
||||
// 0 → (0, 1) 1 → (1, 1) 2 → (1, 0) 3 → (0, 0)
|
||||
// (the v axis is flipped vs. geometric convention — harmless, just a
|
||||
// texture-space choice).
|
||||
int vIdx = gl_VertexID % 6;
|
||||
int corner = 0;
|
||||
if (splitDir == 0u) {
|
||||
|
|
@ -90,12 +105,20 @@ void main() {
|
|||
else baseUV = vec2(0.0, 0.0);
|
||||
|
||||
vBaseUV = baseUV;
|
||||
// Vertices are baked in world space; normals need no model transform.
|
||||
vWorldPos = aPos;
|
||||
vWorldNormal = normalize(aNormal);
|
||||
|
||||
// Lambert diffuse term matching ACME Landscape.vert:
|
||||
// vLightingFactor = max(0.0, dot(vNormal, -normalize(xLightDirection)));
|
||||
vLightingFactor = max(0.0, dot(vWorldNormal, -normalize(xLightDirection)));
|
||||
// Retail AdjustPlanes bake (r13 §7):
|
||||
// L = max(N · -sunDir, MIN_FACTOR)
|
||||
// vertex.color = sun_color * L + ambient_color
|
||||
//
|
||||
// Slot 0 of the UBO is the sun (directional). We read its forward
|
||||
// vector and pre-multiplied color, apply the ambient floor, layer
|
||||
// in the scene ambient separately.
|
||||
vec3 sunDir = uLights[0].dirAndRange.xyz;
|
||||
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
|
||||
float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR);
|
||||
vLightingRGB = sunCol * L + uCellAmbient.xyz;
|
||||
|
||||
float baseTex = float(aPacked0.x);
|
||||
if (baseTex >= 254.0) baseTex = -1.0;
|
||||
|
|
|
|||
292
src/AcDream.App/Rendering/Sky/SkyRenderer.cs
Normal file
292
src/AcDream.App/Rendering/Sky/SkyRenderer.cs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Terrain;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using Silk.NET.OpenGL;
|
||||
|
||||
namespace AcDream.App.Rendering.Sky;
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs</c>.
|
||||
/// Draws the retail sky as a stack of independent celestial meshes (the
|
||||
/// "it's not a dome" insight from r12 §2) rather than a cube/sphere
|
||||
/// with a gradient texture. Each <see cref="SkyObjectData"/> is
|
||||
/// visible in a window of day-fraction space, sweeps from
|
||||
/// <c>BeginAngle</c> to <c>EndAngle</c> across the sky, and samples its
|
||||
/// texture with a per-frame UV scroll driven by <c>TexVelocityX/Y</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// GL state delta per frame:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Depth mask OFF, depth test OFF, cull OFF — the sky
|
||||
/// should never occlude scene geometry.</description></item>
|
||||
/// <item><description>Separate projection matrix with a 0.1–1e6 near/far
|
||||
/// so mesh vertices at large distance don't clip.</description></item>
|
||||
/// <item><description>View matrix with translation zeroed — sky is
|
||||
/// always camera-centred; moving doesn't get you closer to the
|
||||
/// sun.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Meshes are built lazily per GfxObj id on first reference. The
|
||||
/// per-object arc transform matches WorldBuilder's composition:
|
||||
/// <c>scale × RotZ(-heading) × RotY(-rotation)</c> — the negative signs
|
||||
/// come from AC's Z-up right-handed convention where heading is
|
||||
/// measured clockwise from north.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed unsafe class SkyRenderer : IDisposable
|
||||
{
|
||||
private readonly GL _gl;
|
||||
private readonly DatCollection _dats;
|
||||
private readonly Shader _shader;
|
||||
private readonly TextureCache _textures;
|
||||
|
||||
// Lazily-built GPU resources per sky-GfxObj.
|
||||
private readonly Dictionary<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
|
||||
|
||||
// When did we start running — used to accumulate TexVelocityX/Y over
|
||||
// real time (independent of the day-fraction clock).
|
||||
private readonly DateTime _startedAt = DateTime.UtcNow;
|
||||
|
||||
// Configurable render distance — retail uses ~1e6; anything larger
|
||||
// than the scene far plane works.
|
||||
public float Near { get; set; } = 0.1f;
|
||||
public float Far { get; set; } = 1_000_000f;
|
||||
|
||||
public SkyRenderer(GL gl, DatCollection dats, Shader shader, TextureCache textures)
|
||||
{
|
||||
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
_shader = shader ?? throw new ArgumentNullException(nameof(shader));
|
||||
_textures = textures ?? throw new ArgumentNullException(nameof(textures));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw the sky for this frame. Called FIRST in the render loop —
|
||||
/// terrain / meshes / debug lines / overlay land on top.
|
||||
/// </summary>
|
||||
public void Render(
|
||||
ICamera camera,
|
||||
Vector3 cameraWorldPos,
|
||||
float dayFraction,
|
||||
DayGroupData? group)
|
||||
{
|
||||
if (group is null || group.SkyObjects.Count == 0) return;
|
||||
|
||||
// Build a sky projection with a huge far plane so 1e6m-distant
|
||||
// celestial meshes don't clip. The FOV is cargo-culted from the
|
||||
// camera's projection — see WorldBuilder's implementation.
|
||||
float fovY = MathF.PI / 3f; // 60° — matches FlyCamera/ChaseCamera
|
||||
float aspect = camera.Aspect;
|
||||
if (aspect <= 0f) aspect = 16f / 9f;
|
||||
var skyProj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, aspect, Near, Far);
|
||||
|
||||
// View with translation zeroed — keeps the sky at camera origin
|
||||
// regardless of camera position in the world.
|
||||
var skyView = camera.View;
|
||||
skyView.M41 = 0f;
|
||||
skyView.M42 = 0f;
|
||||
skyView.M43 = 0f;
|
||||
|
||||
_shader.Use();
|
||||
_shader.SetMatrix4("uSkyView", skyView);
|
||||
_shader.SetMatrix4("uSkyProjection", skyProj);
|
||||
|
||||
// Save + override GL state.
|
||||
_gl.DepthMask(false);
|
||||
_gl.Disable(EnableCap.DepthTest);
|
||||
_gl.Disable(EnableCap.CullFace);
|
||||
_gl.Enable(EnableCap.Blend);
|
||||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||||
|
||||
// Look up the keyframe's override list so we can apply
|
||||
// SkyObjReplace (r12 §2.3): per-keyframe GfxObj swaps + rotation
|
||||
// override + transparency fade + luminosity cap.
|
||||
var replaces = PickReplaces(group, dayFraction);
|
||||
|
||||
float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds;
|
||||
|
||||
for (int i = 0; i < group.SkyObjects.Count; i++)
|
||||
{
|
||||
var obj = group.SkyObjects[i];
|
||||
if (!obj.IsVisible(dayFraction)) continue;
|
||||
|
||||
// Apply per-keyframe replace overrides.
|
||||
uint gfxObjId = obj.GfxObjId;
|
||||
float headingDeg = 0f;
|
||||
float transparent = 0f;
|
||||
float luminosity = 1f;
|
||||
if (replaces.TryGetValue((uint)i, out var rep))
|
||||
{
|
||||
if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId;
|
||||
if (rep.Rotate != 0f) headingDeg = rep.Rotate;
|
||||
transparent = Math.Clamp(rep.Transparent, 0f, 1f);
|
||||
if (rep.Luminosity > 0f) luminosity = rep.Luminosity;
|
||||
if (rep.MaxBright > 0f) luminosity = MathF.Min(luminosity, rep.MaxBright);
|
||||
}
|
||||
if (gfxObjId == 0) continue;
|
||||
|
||||
// Current arc angle across the sky.
|
||||
float rotationDeg = obj.CurrentAngle(dayFraction);
|
||||
float headingRad = headingDeg * (MathF.PI / 180f);
|
||||
float rotationRad = rotationDeg * (MathF.PI / 180f);
|
||||
|
||||
// Matches WorldBuilder's composition for a Z-up right-handed
|
||||
// frame with heading measured clockwise from north.
|
||||
var model = Matrix4x4.CreateScale(1.0f)
|
||||
* Matrix4x4.CreateRotationZ(-headingRad)
|
||||
* Matrix4x4.CreateRotationY(-rotationRad);
|
||||
|
||||
_shader.SetMatrix4("uModel", model);
|
||||
|
||||
// UV scroll accumulates real-time × velocity. Wrap to [0, 1]
|
||||
// so long-running sessions don't accumulate float precision
|
||||
// loss in the fragment UV.
|
||||
float uOffset = (obj.TexVelocityX * secondsSinceStart) % 1f;
|
||||
float vOffset = (obj.TexVelocityY * secondsSinceStart) % 1f;
|
||||
_shader.SetVec2("uUvScroll", new Vector2(uOffset, vOffset));
|
||||
_shader.SetFloat("uTransparency", transparent);
|
||||
_shader.SetFloat("uLuminosity", luminosity);
|
||||
_shader.SetVec4("uTint", Vector4.One);
|
||||
|
||||
EnsureMeshUploaded(gfxObjId);
|
||||
if (!_gpuByGfxObj.TryGetValue(gfxObjId, out var subMeshes)) continue;
|
||||
|
||||
foreach (var sub in subMeshes)
|
||||
{
|
||||
uint tex = _textures.GetOrUpload(sub.SurfaceId);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, tex);
|
||||
_gl.BindVertexArray(sub.Vao);
|
||||
_gl.DrawElements(PrimitiveType.Triangles,
|
||||
(uint)sub.IndexCount,
|
||||
DrawElementsType.UnsignedInt,
|
||||
(void*)0);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore GL state expected by the rest of the pipeline.
|
||||
_gl.Disable(EnableCap.Blend);
|
||||
_gl.DepthMask(true);
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
_gl.BindVertexArray(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the <see cref="SkyObjectReplaceData"/> entries for the
|
||||
/// keyframe currently "active" at <paramref name="dayFraction"/>.
|
||||
/// Matches WorldBuilder's single-keyframe lookup (it picks <c>t1</c>
|
||||
/// and doesn't interpolate the replace fields).
|
||||
/// </summary>
|
||||
private static Dictionary<uint, SkyObjectReplaceData> PickReplaces(
|
||||
DayGroupData group, float dayFraction)
|
||||
{
|
||||
var result = new Dictionary<uint, SkyObjectReplaceData>();
|
||||
var times = group.SkyTimes;
|
||||
if (times.Count == 0) return result;
|
||||
|
||||
// Pick k1 = last keyframe with Begin <= dayFraction.
|
||||
DatSkyKeyframeData k1 = times[^1];
|
||||
for (int i = 0; i < times.Count; i++)
|
||||
{
|
||||
if (times[i].Keyframe.Begin <= dayFraction)
|
||||
k1 = times[i];
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var r in k1.Replaces)
|
||||
result[r.ObjectIndex] = r;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazy GfxObj build — reuses <see cref="GfxObjMesh"/> so the
|
||||
/// pos/neg polygon splitting logic stays consistent with the main
|
||||
/// static-mesh pipeline. Most sky meshes are single-surface.
|
||||
/// </summary>
|
||||
private void EnsureMeshUploaded(uint gfxObjId)
|
||||
{
|
||||
if (_gpuByGfxObj.ContainsKey(gfxObjId)) return;
|
||||
|
||||
var gfx = _dats.Get<GfxObj>(gfxObjId);
|
||||
if (gfx is null)
|
||||
{
|
||||
_gpuByGfxObj[gfxObjId] = new List<SubMeshGpu>();
|
||||
return;
|
||||
}
|
||||
|
||||
var subMeshes = GfxObjMesh.Build(gfx, _dats);
|
||||
var gpuList = new List<SubMeshGpu>(subMeshes.Count);
|
||||
foreach (var sm in subMeshes)
|
||||
gpuList.Add(UploadSubMesh(sm));
|
||||
_gpuByGfxObj[gfxObjId] = gpuList;
|
||||
}
|
||||
|
||||
private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm)
|
||||
{
|
||||
uint vao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(vao);
|
||||
|
||||
uint vbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
|
||||
fixed (void* p = sm.Vertices)
|
||||
_gl.BufferData(BufferTargetARB.ArrayBuffer,
|
||||
(nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
uint ebo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
|
||||
fixed (void* p = sm.Indices)
|
||||
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
|
||||
(nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
|
||||
|
||||
uint stride = (uint)sizeof(Vertex);
|
||||
_gl.EnableVertexAttribArray(0);
|
||||
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
|
||||
_gl.EnableVertexAttribArray(1);
|
||||
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(2);
|
||||
_gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float)));
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
return new SubMeshGpu
|
||||
{
|
||||
Vao = vao,
|
||||
Vbo = vbo,
|
||||
Ebo = ebo,
|
||||
IndexCount = sm.Indices.Length,
|
||||
SurfaceId = sm.SurfaceId,
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var subs in _gpuByGfxObj.Values)
|
||||
{
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
_gl.DeleteBuffer(sub.Vbo);
|
||||
_gl.DeleteBuffer(sub.Ebo);
|
||||
_gl.DeleteVertexArray(sub.Vao);
|
||||
}
|
||||
}
|
||||
_gpuByGfxObj.Clear();
|
||||
}
|
||||
|
||||
private sealed class SubMeshGpu
|
||||
{
|
||||
public uint Vao;
|
||||
public uint Vbo;
|
||||
public uint Ebo;
|
||||
public int IndexCount;
|
||||
public uint SurfaceId;
|
||||
}
|
||||
}
|
||||
|
|
@ -218,12 +218,11 @@ public sealed unsafe class TerrainChunkRenderer : IDisposable
|
|||
_shader.SetMatrix4("uView", camera.View);
|
||||
_shader.SetMatrix4("uProjection", camera.Projection);
|
||||
|
||||
// Lighting uniforms matching ACME Landscape.vert/frag.
|
||||
// LightDirection (0.5, 0.3, -0.3) from ACME GameScene.cs:238.
|
||||
// AmbientLightIntensity 0.45 from ACME LandscapeEditorSettings.cs:108.
|
||||
var lightDir = Vector3.Normalize(new Vector3(0.5f, 0.3f, -0.3f));
|
||||
_shader.SetVec3("xLightDirection", lightDir);
|
||||
_shader.SetFloat("xAmbient", 0.45f);
|
||||
// Phase G: light direction + ambient + fog come from the shared
|
||||
// SceneLighting UBO (binding=1) uploaded by GameWindow once per
|
||||
// frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7)
|
||||
// from the UBO's slot-0 sun + uCellAmbient, then the fragment
|
||||
// stage adds fog + lightning flash. No per-program uniforms here.
|
||||
|
||||
// Terrain atlas on unit 0, alpha atlas on unit 1.
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
|
|
|
|||
150
src/AcDream.Core/Lighting/SceneLightingUbo.cs
Normal file
150
src/AcDream.Core/Lighting/SceneLightingUbo.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// GPU-facing scene-lighting UBO layout. Matches the GLSL block in
|
||||
/// <c>mesh.frag</c> / <c>mesh_instanced.vert</c> / <c>terrain.vert</c>
|
||||
/// bound at binding=1. std140-compliant — each <c>vec4</c> member
|
||||
/// lives on a 16-byte boundary, arrays of <c>vec4</c> pack contiguously,
|
||||
/// and no pad elements are required because the struct's fields are
|
||||
/// already 16-byte-aligned.
|
||||
///
|
||||
/// <para>
|
||||
/// Layout (r13 §12.3 extended with R12 §13.2 sun+fog):
|
||||
/// <code>
|
||||
/// struct Light {
|
||||
/// vec4 posAndKind; // xyz = world pos, w = kind (0=dir, 1=point, 2=spot)
|
||||
/// vec4 dirAndRange; // xyz = forward, w = range (metres, hard cutoff)
|
||||
/// vec4 colorAndIntensity; // xyz = RGB linear, w = intensity scalar
|
||||
/// vec4 coneAngleEtc; // x = cone (rad), y=unused, z=unused, w=unused
|
||||
/// };
|
||||
///
|
||||
/// layout(std140, binding = 1) uniform SceneLighting {
|
||||
/// Light uLights[8]; // 8 * 64 bytes = 512 bytes
|
||||
/// vec4 uCellAmbient; // xyz = ambient RGB, w = active light count
|
||||
/// vec4 uFogParams; // x = fogStart, y = fogEnd, z = lightningFlash, w = fogMode
|
||||
/// vec4 uFogColor; // xyz = fog RGB, w = unused
|
||||
/// vec4 uCameraAndTime; // xyz = camera world pos, w = day fraction (debug / sky shader)
|
||||
/// };
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct UboLight
|
||||
{
|
||||
public Vector4 PosAndKind;
|
||||
public Vector4 DirAndRange;
|
||||
public Vector4 ColorAndIntensity;
|
||||
public Vector4 ConeAngleEtc;
|
||||
|
||||
/// <summary>Pack a <see cref="LightSource"/> into UBO-ready bytes.</summary>
|
||||
public static UboLight FromSource(LightSource ls)
|
||||
{
|
||||
return new UboLight
|
||||
{
|
||||
PosAndKind = new Vector4(ls.WorldPosition, (float)(int)ls.Kind),
|
||||
DirAndRange = new Vector4(ls.WorldForward, ls.Range),
|
||||
ColorAndIntensity = new Vector4(ls.ColorLinear, ls.Intensity),
|
||||
ConeAngleEtc = new Vector4(ls.ConeAngle, 0f, 0f, 0f),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Packed "zero" light — stored in unused UBO slots so shaders
|
||||
/// don't read garbage. <c>dirAndRange.w = 0</c> disables the light
|
||||
/// even if the active-count sentinel is wrong.</summary>
|
||||
public static UboLight Empty => new()
|
||||
{
|
||||
PosAndKind = Vector4.Zero,
|
||||
DirAndRange = Vector4.Zero,
|
||||
ColorAndIntensity = Vector4.Zero,
|
||||
ConeAngleEtc = Vector4.Zero,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full CPU-side scene-lighting UBO buffer. One per frame; lives on the
|
||||
/// render thread. The GL-side wrapper (<c>SceneLightingUboBinding</c>
|
||||
/// in AcDream.App) uploads this to binding=1 once per frame.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct SceneLightingUbo
|
||||
{
|
||||
// 8 lights × 64 bytes = 512 bytes
|
||||
public UboLight Light0;
|
||||
public UboLight Light1;
|
||||
public UboLight Light2;
|
||||
public UboLight Light3;
|
||||
public UboLight Light4;
|
||||
public UboLight Light5;
|
||||
public UboLight Light6;
|
||||
public UboLight Light7;
|
||||
|
||||
public Vector4 CellAmbient; // xyz = ambient RGB, w = active count
|
||||
public Vector4 FogParams; // x = fogStart, y = fogEnd, z = flash, w = fogMode
|
||||
public Vector4 FogColor; // xyz = color, w = reserved
|
||||
public Vector4 CameraAndTime; // xyz = camera pos, w = day fraction
|
||||
|
||||
public const int SizeInBytes = 8 * 64 + 4 * 16; // 576
|
||||
public const int BindingPoint = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Build the full per-frame UBO payload from:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>An already-ticked <see cref="LightManager"/>.</description></item>
|
||||
/// <item><description>The current <see cref="AtmosphereSnapshot"/> (sky + weather).</description></item>
|
||||
/// <item><description>The current camera world position (sky shader needs it, fog shader needs it).</description></item>
|
||||
/// <item><description>The current day fraction (sky shader needs it for scrolling clouds).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static SceneLightingUbo Build(
|
||||
LightManager lights,
|
||||
in AtmosphereSnapshot atmo,
|
||||
Vector3 cameraWorldPos,
|
||||
float dayFraction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lights);
|
||||
|
||||
var ubo = new SceneLightingUbo();
|
||||
|
||||
// Pack up to 8 lights. Empty slots stay zero.
|
||||
var active = lights.Active;
|
||||
int count = active.Length;
|
||||
if (count > 8) count = 8;
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
var packed = (i < count && active[i] is not null)
|
||||
? UboLight.FromSource(active[i]!)
|
||||
: UboLight.Empty;
|
||||
SetLightAt(ref ubo, i, packed);
|
||||
}
|
||||
|
||||
ubo.CellAmbient = new Vector4(lights.CurrentAmbient.AmbientColor, count);
|
||||
ubo.FogParams = new Vector4(
|
||||
atmo.FogStart,
|
||||
atmo.FogEnd,
|
||||
atmo.LightningFlash,
|
||||
(float)(int)atmo.FogMode);
|
||||
ubo.FogColor = new Vector4(atmo.FogColor, 0f);
|
||||
ubo.CameraAndTime = new Vector4(cameraWorldPos, dayFraction);
|
||||
return ubo;
|
||||
}
|
||||
|
||||
private static void SetLightAt(ref SceneLightingUbo ubo, int i, in UboLight v)
|
||||
{
|
||||
switch (i)
|
||||
{
|
||||
case 0: ubo.Light0 = v; break;
|
||||
case 1: ubo.Light1 = v; break;
|
||||
case 2: ubo.Light2 = v; break;
|
||||
case 3: ubo.Light3 = v; break;
|
||||
case 4: ubo.Light4 = v; break;
|
||||
case 5: ubo.Light5 = v; break;
|
||||
case 6: ubo.Light6 = v; break;
|
||||
case 7: ubo.Light7 = v; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue