diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a779a..368ec0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [Unreleased] + +### Added + +- **3D:** `RenderBuffer3D.CameraBlockCount` counts `BeginCamera`/`BeginCameraConfig` commands added since the last `Clear`, on both backends. The pipelines use it to skip the per-camera-block plan walk (and its per-frame allocations) for single-camera frames. + +### Changed + +- **MonoGame 3D:** **Breaking (behavioral):** in frames with more than one camera block, lights (ambient, directional, point, spot), the shadow origin, and shadow casting are now scoped per camera block. A block that sets its own lights starts from the frame defaults (lights emitted before the first camera block or between blocks) and applies them in order; a block that sets none inherits the running set. Each camera block renders its own shadow map, so multi-block frames cost one shadow pass per block. Single-camera frames are unchanged. +- **Raylib 3D:** **Breaking (behavioral):** the same per-camera-block scoping: lights no longer accumulate across blocks that set their own lights, and the shadow pass runs per camera block instead of once per frame. Single-camera frames are unchanged. +- **3D:** **Breaking (behavioral):** only the first directional light is shaded, and only it can cast shadows, on both backends. Previously a frame whose first directional light didn't cast could still be shadowed by a later casting light's shadow map, and a casting directional light could render a shadow map nothing sampled. +- **Raylib 3D:** the per-shadow-pass point/spot shadow-slot arrays are now grow-only pipeline scratch (matching the MonoGame backend) instead of fresh arrays per pass, and the pre-scan no longer gathers lights frame-globally in multi-camera-block frames (the per-block forward pass builds them). +- **MonoGame 3D:** the shadow/depth passes no longer allocate a `RenderTargetBinding[]` per pass to save the caller's render-target bindings; the bindings are saved into pooled scratch resized only when the bound count changes. + +### Deprecated + +- **3D:** `LightBuffers.defaults` is obsolete on both backends: it is a single shared mutable accumulator, so every consumer aliases the same light buffers. Use `LightBuffers.create` for per-instance state instead. + +### Fixed + +- **3D:** in multi-camera-block frames, live shading no longer diverges from the block plan: between-block lights were applied twice to blocks that set their own lights, blocks that set no lights were shaded by between-block and after-last-block lights the plan (and their shadow pass) didn't include, and after-last-block lights leaked into blocks that reset. The live light sets and frame defaults are now built in-order during the forward pass instead of seeded from the plan's frame defaults. +- **MonoGame 3D:** a camera block's `ClearColor` combined with a custom `Viewport` no longer clears the whole frame. The block clear is drawn as a viewport-covering triangle instead of `gd.Clear`, which ignores the viewport (D3D `ClearRenderTargetView` semantics) and wiped every previously rendered camera block (split-screen). +- **MonoGame:** the backbuffer is now created with `RenderTargetUsage.PreserveContents`. On the DX12-native backend, rebinding the backbuffer after a mid-frame render-target switch (the shadow atlas, the post-process scene RT) discarded everything drawn before the switch — in multi-camera-block frames, earlier camera blocks and the frame clear were wiped. Games can still override via a device-config callback. +- **MonoGame 3D:** two live 3D pipelines no longer bleed lights into each other; each pipeline now accumulates lights in its own buffers. +- **Raylib 3D:** a throw during a per-camera-block shadow pass no longer leaves the pipeline outside the scene render target's texture mode; the caller's texture mode is re-wrapped in a `finally`. + ## [3.3.0] - 2026-07-25 ### Added diff --git a/docs/camera.md b/docs/camera.md index 2602d3c..417301b 100644 --- a/docs/camera.md +++ b/docs/camera.md @@ -251,6 +251,13 @@ buffer .drop() ``` +Each split-screen half is its own camera block, so lights and shadows can differ per +view: a block that sets no lights inherits the scene's running set, while a block that +sets its own lights (an indoor and an outdoor world, day and night sides) starts from +the frame defaults and renders its own shadow map. See +[Buffers & Commands → Light scoping](graphics3d/buffer-and-commands.html#light-scoping-across-camera-blocks) +and [3D Lighting → Lights across camera blocks](graphics3d/lighting.html#lights-across-camera-blocks). + ### Mouse picking Cast a ray from a screen position into the 3D scene with `Camera3D.screenPointToRay` (both backends): diff --git a/docs/graphics3d/buffer-and-commands.md b/docs/graphics3d/buffer-and-commands.md index b86e535..1b4cfa6 100644 --- a/docs/graphics3d/buffer-and-commands.md +++ b/docs/graphics3d/buffer-and-commands.md @@ -125,7 +125,7 @@ buffer ## Lighting setup -Add lights before geometry. Lights affect all subsequent draws: +Add lights before geometry. Within a camera block, lights affect all subsequent draws in that block: ```fsharp buffer @@ -152,6 +152,17 @@ buffer > _**TIP**_: You can call `.addPointLight(...)` in a loop for dynamic lights. +### Light scoping across camera blocks + +In a single-camera buffer, lights are frame-global: every light command applies to every draw. In a buffer with more than one camera block, lights are scoped **per camera block**: + +- **Frame defaults** — light commands emitted outside any camera block (before the first one, or between two) accumulate into the frame defaults. +- **Reset** — a block that issues its own light commands starts from the frame defaults, then applies its own commands in order (a later ambient overwrites the earlier one; directional, point, and spot lights append). +- **Inherit** — a block that issues no light commands inherits the running set: the previous block's lights plus any light commands emitted between the two blocks. +- **After the last block** — light commands emitted after the final `.endCamera()` affect nothing. + +Light state is tracked per light type, and a block can only add to the set it inherits — it cannot remove an inherited light. Shadows follow the same scoping: `.setShadowOrigin(...)` applies only to the block it appears in, and each block with shadow-casting lights renders its own shadow map. + ## See also - [Draw DSL](../draw-dsl.html) — the full fluent draw surface (2D and 3D) diff --git a/docs/graphics3d/lighting.md b/docs/graphics3d/lighting.md index a12d638..7fc87dc 100644 --- a/docs/graphics3d/lighting.md +++ b/docs/graphics3d/lighting.md @@ -157,7 +157,31 @@ let pipeline = ForwardPbrPipeline( `MAX_SPOT_LIGHTS` constants in `ForwardPbr.fx`). The `ForwardPipeline` constructor takes no light-count argument; to change them you recompile the `.fx` with different `#define`s. -Exceeding the limit silently drops extra lights on both backends. +Budgets apply **per camera block**: in buffers with more than one camera block, each block +gets its own ambient slot and light arrays (see +[Lights across camera blocks](#lights-across-camera-blocks)); in single-camera buffers they +apply to the whole frame. + +Exceeding the limit silently drops extra lights on both backends. For directional lights +specifically, only the **first** directional light in the active set is shaded, and only it +can cast shadows — later directional lights in the same set are ignored entirely. + +## Lights across camera blocks + +In single-camera buffers, lights are frame-global. In buffers with more than one camera +block, lights are scoped per camera block: a block that issues its own light commands starts +from the frame defaults (lights emitted before the first camera block or between blocks) and +applies its own commands in order; a block that issues none inherits the previous block's set +plus any lights emitted in between. A block can add lights but cannot remove inherited ones, +and lights emitted after the last block affect nothing. See +[Buffers & Commands → Light scoping](buffer-and-commands.html#light-scoping-across-camera-blocks) +for the full placement rules. + +Shadows follow the same scoping. Each camera block with shadow-casting lights renders its own +shadow map, so a multi-block buffer costs one shadow pass per block. `.setShadowOrigin(...)` +applies only to the block it appears in; the `.enableShadows()`/`.disableShadows()` toggle +carries into later blocks' initial state. Single-camera buffers behave exactly as single-pass +frames — one shadow map for the whole frame, no per-block cost. ## Shadow configuration diff --git a/docs/graphics3d/overview.md b/docs/graphics3d/overview.md index e3083f4..02b7ac8 100644 --- a/docs/graphics3d/overview.md +++ b/docs/graphics3d/overview.md @@ -243,6 +243,12 @@ buffer > produce backend-appropriate rectangles; for a picture-in-picture view, compose > `render` + `withViewport` + `withClear` yourself and emit that camera after the main one. +> _**NOTE — lights and shadows are scoped per camera block.**_ A view that sets no +> lights (like the minimap above) inherits the running set, so same-world multi-view +> works with no extra setup. A view that sets its own lights starts from the frame +> defaults instead — useful when the views show different worlds. See +> [Buffers & Commands → Light scoping](buffer-and-commands.html#light-scoping-across-camera-blocks). + See [Camera](../camera.html) for the full `Camera3DConfig` API. ## 2D overlay on 3D diff --git a/docs/shader-uniforms.md b/docs/shader-uniforms.md index bf21496..5da0a4a 100644 --- a/docs/shader-uniforms.md +++ b/docs/shader-uniforms.md @@ -95,7 +95,9 @@ on MonoGame). ### Shadows (opt-in by declaration) -Only uploaded when the frame produced a shadow atlas. A shader that declares +Only uploaded when the active camera block produced a shadow atlas — in buffers with +more than one camera block these are re-uploaded at each block's start and always +describe the block being drawn. A shader that declares none of these renders unshadowed at no cost. | Uniform | Type | Source | @@ -241,8 +243,8 @@ fields (as F# values, not shader uniforms): | `View` | camera view matrix | | | `Projection` | camera projection matrix | | | `Camera` | active `Camera3D` | position, target, up, fov, planes | -| `Lights` | `LightBuffers` | ambient + directional + point + spot accumulators | -| `Shadows` | `ShadowResult voption` | `ValueNone` when no shadow-casting light | +| `Lights` | `LightBuffers` | ambient + directional + point + spot accumulators (in multi-block buffers, the current camera block's set) | +| `Shadows` | `ShadowResult voption` | `ValueNone` when no shadow-casting light (in multi-block buffers, the current camera block's shadow pass output) | | `Time` | `float32` | Total elapsed game time, seconds | Set whatever uniforms your own shader needs directly from these values. diff --git a/src/Mibo.MonoGame.Tests/BlockPlanTests.fs b/src/Mibo.MonoGame.Tests/BlockPlanTests.fs new file mode 100644 index 0000000..5e8d116 --- /dev/null +++ b/src/Mibo.MonoGame.Tests/BlockPlanTests.fs @@ -0,0 +1,534 @@ +module Mibo.MonoGame.Tests.BlockPlanTests + +open Expecto +open System.Numerics +open Mibo.Elmish +open Mibo.Elmish.Graphics3D +open Mibo.Elmish.Graphics3D.Pipelines + +let private cam = Unchecked.defaultof + +let private planOf(cmds: Command3D list) = + use buffer = new RenderBuffer3D() + + for cmd in cmds do + buffer.Add cmd + + BlockPlan.build buffer + +/// Builds the plan AND replays the buffer the way the multi-camera-block forward pass +/// reconstructs live light sets (no FrameDefaults seeding — regression hook for the +/// live-shading divergence found in PR #89 review). +let private replayOf(cmds: Command3D list) = + use buffer = new RenderBuffer3D() + + for cmd in cmds do + buffer.Add cmd + + let plan = BlockPlan.build buffer + let lights = LightBuffers.create 1 4 4 + let defaults = LightBuffers.create 1 4 4 + plan, LightScoping.replay buffer plan lights defaults + +let private ambient(intensity: float32) = + AmbientLight3D.create Mibo.Color.White + |> AmbientLight3D.withIntensity intensity + +let private dir castsShadows (direction: Vector3) = + DirectionalLight3D.create direction + |> DirectionalLight3D.withCastsShadows castsShadows + +let private point(at: Vector3) = PointLight3D.create(at, 10.0f) + +let private spot(at: Vector3) = + SpotLight3D.create(at, Vector3.UnitY, 20.0f) + +[] +let blockPlanTests = + testList "BlockPlan (MonoGame)" [ + test "empty buffer has no camera blocks" { + let plan = planOf [] + Expect.equal plan.BlockCount 0 "BlockCount" + Expect.isEmpty plan.Blocks "Blocks" + } + + test "single camera block counts once" { + let plan = planOf [ Command3D.BeginCamera cam; Command3D.EndCamera ] + Expect.equal plan.BlockCount 1 "BlockCount" + Expect.equal plan.Blocks.Length 1 "Blocks" + } + + test "two camera blocks count twice" { + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.equal plan.BlockCount 2 "BlockCount" + } + + test "nested BeginCamera closes the previous block" { + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.equal plan.BlockCount 2 "BlockCount" + } + + test "a block with light commands resets to defaults plus its own" { + let p1 = point Vector3.Zero + let dB = dir false Vector3.UnitX + + let plan = + planOf [ + Command3D.SetAmbientLight(ambient 0.5f) + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.1f) + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.2f) + Command3D.AddDirectionalLight dB + Command3D.EndCamera + ] + + let block1 = plan.Blocks[0].Lights + Expect.equal block1.Ambient (ValueSome(ambient 0.1f)) "block1 ambient" + Expect.sequenceEqual block1.PointLights [ p1 ] "block1 points" + Expect.isEmpty block1.DirLights "block1 dirs" + + let block2 = plan.Blocks[1].Lights + Expect.equal block2.Ambient (ValueSome(ambient 0.2f)) "block2 ambient" + Expect.sequenceEqual block2.DirLights [ dB ] "block2 dirs" + + Expect.isEmpty + block2.PointLights + "block2 does not inherit block1's own lights" + } + + test + "a block without light commands inherits the previous block's final set" { + let p1 = point Vector3.Zero + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.3f) + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + for i = 0 to 2 do + let lights = plan.Blocks[i].Lights + + Expect.equal + lights.Ambient + (ValueSome(ambient 0.3f)) + $"block{i} ambient" + + Expect.sequenceEqual lights.PointLights [ p1 ] $"block{i} points" + } + + test "lights before the first BeginCamera appear in every block" { + let d0 = dir false Vector3.UnitY + let p1 = point Vector3.One + + let plan = + planOf [ + Command3D.SetAmbientLight(ambient 0.4f) + Command3D.AddDirectionalLight d0 + Command3D.BeginCamera cam + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.equal + plan.FrameDefaults.Ambient + (ValueSome(ambient 0.4f)) + "defaults ambient" + + Expect.sequenceEqual plan.FrameDefaults.DirLights [ d0 ] "defaults dirs" + + let block1 = plan.Blocks[0].Lights + Expect.equal block1.Ambient (ValueSome(ambient 0.4f)) "block1 ambient" + Expect.sequenceEqual block1.DirLights [ d0 ] "block1 dirs" + Expect.sequenceEqual block1.PointLights [ p1 ] "block1 points" + + let block2 = plan.Blocks[1].Lights + Expect.equal block2.Ambient (ValueSome(ambient 0.4f)) "block2 ambient" + Expect.sequenceEqual block2.DirLights [ d0 ] "block2 dirs" + Expect.sequenceEqual block2.PointLights [ p1 ] "block2 points" + } + + test + "between-block commands update defaults; after-last-block commands affect no block" { + let dBetween = dir false Vector3.UnitX + let p2 = point Vector3.One + let sAfter = spot Vector3.Zero + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.AddPointLight p2 + Command3D.EndCamera + Command3D.AddSpotLight sAfter + ] + + Expect.isEmpty + plan.Blocks[0].Lights.DirLights + "block1 predates the between-block dir" + + let block2 = plan.Blocks[1].Lights + + Expect.sequenceEqual + block2.DirLights + [ dBetween ] + "block2 sees the updated defaults" + + Expect.sequenceEqual block2.PointLights [ p2 ] "block2 points" + Expect.isEmpty block2.SpotLights "after-last-block spot affects no block" + + Expect.sequenceEqual + plan.FrameDefaults.SpotLights + [ sAfter ] + "after-last-block spot lands in the defaults" + } + + test "an empty block inherits between-block commands via the running set" { + let p1 = point Vector3.Zero + let dBetween = dir false Vector3.UnitX + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + let block2 = plan.Blocks[1].Lights + + Expect.sequenceEqual + block2.PointLights + [ p1 ] + "block2 keeps block1's lights" + + Expect.sequenceEqual + block2.DirLights + [ dBetween ] + "block2 sees the between-block dir" + } + + test + "a resetting block after between-block commands sees them via the defaults" { + let p1 = point Vector3.Zero + let dBetween = dir false Vector3.UnitX + let s2 = spot Vector3.One + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.AddSpotLight s2 + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + let block2 = plan.Blocks[1].Lights + + Expect.sequenceEqual + block2.DirLights + [ dBetween ] + "block2 sees the between-block dir" + + Expect.sequenceEqual block2.SpotLights [ s2 ] "block2 spots" + + Expect.isEmpty + block2.PointLights + "a resetting block does not inherit block1's lights" + + let block3 = plan.Blocks[2].Lights + + Expect.sequenceEqual + block3.DirLights + [ dBetween ] + "block3 inherits block2's final set" + + Expect.sequenceEqual block3.SpotLights [ s2 ] "block3 spots" + } + + test "block slices cover the block's commands, half-open" { + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.EnableShadows + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.DisableShadows + ] + + Expect.equal plan.Blocks[0].StartIndex 1 "block1 start" + Expect.equal plan.Blocks[0].EndIndex 2 "block1 end" + Expect.equal plan.Blocks[1].StartIndex 4 "block2 start" + + Expect.equal + plan.Blocks[1].EndIndex + 5 + "an unclosed trailing block ends at the buffer end" + } + + test "each block sees exactly its own directional light" { + let dNoCast = dir false Vector3.UnitX + let dCast = dir true Vector3.UnitY + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.1f) + Command3D.AddDirectionalLight dNoCast + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.6f) + Command3D.AddDirectionalLight dCast + Command3D.EndCamera + ] + + let dirs1 = plan.Blocks[0].Lights.DirLights + Expect.equal dirs1.Length 1 "block1 dir count" + Expect.isFalse dirs1[0].CastsShadows "block1 dir does not cast" + + let dirs2 = plan.Blocks[1].Lights.DirLights + Expect.equal dirs2.Length 1 "block2 dir count" + Expect.isTrue dirs2[0].CastsShadows "block2 dir casts" + } + + test + "DisableShadows before a block sets that block's initial cast state off" { + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.DisableShadows + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.isTrue plan.Blocks[0].InitialCastEnabled "block1 initial" + Expect.isFalse plan.Blocks[1].InitialCastEnabled "block2 initial" + } + + test "EnableShadows mid-block carries into the next block's initial state" { + let plan = + planOf [ + Command3D.DisableShadows + Command3D.BeginCamera cam + Command3D.EnableShadows + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.isFalse plan.Blocks[0].InitialCastEnabled "block1 initial" + Expect.isTrue plan.Blocks[1].InitialCastEnabled "block2 initial" + } + + test "SetShadowOrigin does not leak across blocks" { + let origin = Microsoft.Xna.Framework.Vector3(1.0f, 2.0f, 3.0f) + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.SetShadowOrigin origin + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.equal + plan.Blocks[0].ShadowOrigin + (ValueSome origin) + "block1 origin" + + Expect.equal plan.Blocks[1].ShadowOrigin ValueNone "block2 origin" + } + + test "LightBuffers.copyInto copies contents without stale entries" { + // LightBuffers.create — LightBuffers.defaults is a shared module-level instance. + let source = LightBuffers.create 3 8 4 + source.Ambient <- ValueSome(ambient 0.7f) + source.DirLights.Add(dir true Vector3.UnitY) + source.PointLights.Add(point Vector3.Zero) + + let target = LightBuffers.create 3 8 4 + target.DirLights.Add(dir false Vector3.UnitX) + target.DirLights.Add(dir false Vector3.One) + target.SpotLights.Add(spot Vector3.One) + + LightBuffers.copyInto source target + + Expect.equal target.Ambient (ValueSome(ambient 0.7f)) "ambient" + Expect.sequenceEqual target.DirLights (Seq.toList source.DirLights) "dirs" + + Expect.sequenceEqual + target.PointLights + (Seq.toList source.PointLights) + "points" + + Expect.isEmpty target.SpotLights "stale spots are gone" + } + + // ── Live-replay regression tests (PR #89 review) ── + // The forward pass must shade each block with exactly plan.Blocks[k].Lights. + // Seeding the live buffers from plan.FrameDefaults diverged (double-applied + // between-block lights, future lights in early blocks, after-last-block leaks); + // the replay pins the seed-free protocol against the plan. + + test "live replay: between-block light is applied once, not twice" { + let dBetween = dir false Vector3.UnitX + let p2 = point Vector3.Zero + + let plan, sets = + replayOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.AddPointLight p2 + Command3D.EndCamera + ] + + Expect.sequenceEqual + sets[1].DirLights + [ dBetween ] + "block2 dirs applied exactly once" + + Expect.sequenceEqual + sets[1].DirLights + plan.Blocks[1].Lights.DirLights + "replay matches the plan" + } + + test "live replay: an early inheriting block does not see future lights" { + let dBetween = dir false Vector3.UnitX + + let plan, sets = + replayOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.isEmpty sets[0].DirLights "block1 predates the between-block dir" + + Expect.sequenceEqual + sets[1].DirLights + [ dBetween ] + "block2 inherits the running set" + + for i = 0 to 1 do + Expect.sequenceEqual + sets[i].DirLights + plan.Blocks[i].Lights.DirLights + $"block{i} matches the plan" + } + + test "live replay: after-last-block lights affect no block" { + let p2 = point Vector3.Zero + let sAfter = spot Vector3.Zero + + let plan, sets = + replayOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.AddPointLight p2 + Command3D.EndCamera + Command3D.AddSpotLight sAfter + ] + + Expect.equal sets.Length plan.BlockCount "one set per block" + Expect.isEmpty sets[1].SpotLights "after-last-block spot affects no block" + + for i = 0 to sets.Length - 1 do + Expect.sequenceEqual + sets[i].PointLights + plan.Blocks[i].Lights.PointLights + $"block{i} points match the plan" + + Expect.sequenceEqual + sets[i].SpotLights + plan.Blocks[i].Lights.SpotLights + $"block{i} spots match the plan" + } + + test "live replay: a resetting block starts from the defaults at its start" { + let p0 = point Vector3.One + let dBetween = dir false Vector3.UnitY + let p2 = point Vector3.Zero + + let plan, sets = + replayOf [ + Command3D.AddPointLight p0 + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.AddPointLight p2 + Command3D.EndCamera + ] + + Expect.sequenceEqual + sets[1].PointLights + [ p0; p2 ] + "defaults points, then the block's own" + + Expect.sequenceEqual + sets[1].DirLights + [ dBetween ] + "between-block dir joins the defaults" + + for i = 0 to sets.Length - 1 do + Expect.equal + sets[i].Ambient + plan.Blocks[i].Lights.Ambient + $"block{i} ambient matches the plan" + + Expect.sequenceEqual + sets[i].DirLights + plan.Blocks[i].Lights.DirLights + $"block{i} dirs match the plan" + + Expect.sequenceEqual + sets[i].PointLights + plan.Blocks[i].Lights.PointLights + $"block{i} points match the plan" + + Expect.sequenceEqual + sets[i].SpotLights + plan.Blocks[i].Lights.SpotLights + $"block{i} spots match the plan" + } + ] diff --git a/src/Mibo.MonoGame.Tests/Layout3DTests.fs b/src/Mibo.MonoGame.Tests/Layout3DTests.fs index 77e891a..435afea 100644 --- a/src/Mibo.MonoGame.Tests/Layout3DTests.fs +++ b/src/Mibo.MonoGame.Tests/Layout3DTests.fs @@ -204,4 +204,18 @@ let tests = (cmdSequence buf) [| "begin"; "draw"; "end" |] "key scope via fluent" + + testCase "CameraBlockCount tracks camera-block commands and resets on Clear" + <| fun _ -> + use buf = new RenderBuffer3D() + Expect.equal buf.CameraBlockCount 0 "Fresh buffer has no camera blocks" + + buf.Add(Command3D.BeginCamera(Unchecked.defaultof)) + buf.Add Command3D.EndCamera + buf.Add(Command3D.BeginCamera(Unchecked.defaultof)) + + Expect.equal buf.CameraBlockCount 2 "Counts only Begin camera commands" + + buf.Clear() + Expect.equal buf.CameraBlockCount 0 "Clear should reset CameraBlockCount" ] diff --git a/src/Mibo.MonoGame.Tests/Mibo.MonoGame.Tests.fsproj b/src/Mibo.MonoGame.Tests/Mibo.MonoGame.Tests.fsproj index e98970a..b236509 100644 --- a/src/Mibo.MonoGame.Tests/Mibo.MonoGame.Tests.fsproj +++ b/src/Mibo.MonoGame.Tests/Mibo.MonoGame.Tests.fsproj @@ -12,6 +12,8 @@ + + diff --git a/src/Mibo.MonoGame.Tests/ShadowPassTests.fs b/src/Mibo.MonoGame.Tests/ShadowPassTests.fs new file mode 100644 index 0000000..61e54e3 --- /dev/null +++ b/src/Mibo.MonoGame.Tests/ShadowPassTests.fs @@ -0,0 +1,87 @@ +module Mibo.MonoGame.Tests.ShadowPass + +open System.Numerics +open Expecto +open Mibo.Elmish +open Mibo.Elmish.Graphics3D +open Mibo.Elmish.Graphics3D.Pipelines + +let private cam = Unchecked.defaultof + +let private resources() = + ShadowResources(ShadowAtlasConfig.defaults, ShadowBiasConfig.defaults) + +let private dir castsShadows = + DirectionalLight3D.create Vector3.UnitY + |> DirectionalLight3D.withCastsShadows castsShadows + +let private castingPoint = + PointLight3D.create(Vector3.Zero, 10.0f) |> PointLight3D.withCastsShadows true + +[] +let shadowPassTests = + testList "ShadowPass.registerCasters (MonoGame)" [ + test "casters re-pack from slot 0 per registration" { + let res = resources() + let lights = LightBuffers.create 3 8 4 + lights.DirLights.Add(dir true) + lights.PointLights.Add castingPoint + lights.PointLights.Add castingPoint + res.PointShadowSlots <- Array.create 2 -1 + + let count = + ShadowPass.registerCasters ShadowAtlasConfig.defaults res lights cam + + Expect.equal count 3 "dir + 2 point casters" + + Expect.sequenceEqual + res.PointShadowSlots + [ 1; 2 ] + "dir takes slot 0, points follow" + + // A later registration with fewer casters re-packs from slot 0 (per-block shadow + // passes re-register each block's light set from scratch). + let later = LightBuffers.create 3 8 4 + later.PointLights.Add castingPoint + res.PointShadowSlots <- Array.create 1 -1 + + let laterCount = + ShadowPass.registerCasters ShadowAtlasConfig.defaults res later cam + + Expect.equal laterCount 1 "one point caster" + Expect.sequenceEqual res.PointShadowSlots [ 0 ] "re-packed from slot 0" + } + + test "only the first directional light can cast" { + let res = resources() + + let nonCastingFirst = LightBuffers.create 3 8 4 + nonCastingFirst.DirLights.Add(dir false) + nonCastingFirst.DirLights.Add(dir true) + + let count = + ShadowPass.registerCasters + ShadowAtlasConfig.defaults + res + nonCastingFirst + cam + + Expect.equal + count + 0 + "a non-casting DirLights[0] means no directional caster" + + let castingFirst = LightBuffers.create 3 8 4 + castingFirst.DirLights.Add(dir true) + castingFirst.DirLights.Add(dir false) + + let count2 = + ShadowPass.registerCasters + ShadowAtlasConfig.defaults + res + castingFirst + cam + + Expect.equal count2 1 "DirLights[0] casts" + } + ] diff --git a/src/Mibo.MonoGame/Graphics3D/Pipelines/BlockPlan.fs b/src/Mibo.MonoGame/Graphics3D/Pipelines/BlockPlan.fs new file mode 100644 index 0000000..bddb6c6 --- /dev/null +++ b/src/Mibo.MonoGame/Graphics3D/Pipelines/BlockPlan.fs @@ -0,0 +1,272 @@ +namespace Mibo.Elmish.Graphics3D.Pipelines + +open Microsoft.Xna.Framework +open Mibo.Elmish.Graphics3D + +/// +/// An immutable snapshot of a light set: the ambient slot plus the directional, point, and +/// spot light arrays. Light-state semantics are per light type — each kind is an +/// independent array, so a set may hold any number of directional lights. +/// +[] +type BlockLightSet = { + /// The ambient light (single slot; a later ambient command overwrites the earlier one). + Ambient: AmbientLight3D voption + + /// The directional lights, in command order. + DirLights: DirectionalLight3D[] + + /// The point lights, in command order. + PointLights: PointLight3D[] + + /// The spot lights, in command order. + SpotLights: SpotLight3D[] +} + +/// The plan for one camera block (BeginCamera/BeginCameraConfig … EndCamera). +[] +type CameraBlockPlan = { + /// Whether the block contains at least one light command. + HasLightCommands: bool + + /// + /// The block's final light set. A block with light commands starts from the frame defaults + /// and applies its own commands in-order (ambient overwrites; directional/point/spot append). + /// A block without light commands inherits the running light set at its start: the previous + /// block's final set plus any light commands issued between the two blocks. + /// + Lights: BlockLightSet + + /// The block's shadow origin override, if SetShadowOrigin was issued inside it (last one wins). + ShadowOrigin: Vector3 voption + + /// The running EnableShadows/DisableShadows toggle state at the block's start. + InitialCastEnabled: bool + + /// The buffer index of the block's first command (one past its Begin command). + StartIndex: int + + /// The buffer index one past the block's last command (its EndCamera, the next + /// Begin, or the end of the buffer) — the block's commands are the half-open range + /// [StartIndex, EndIndex). + EndIndex: int +} + +/// +/// The result of walking a once: how many +/// camera blocks the frame contains, the per-block light/shadow state, and the frame-default +/// light set that blocks with their own light commands reset to. +/// +/// +/// Light commands outside any camera block accumulate into the frame defaults — including +/// commands between blocks (after an EndCamera, before the next BeginCamera) and +/// after the last EndCamera (those affect no block). Between-block commands also join the +/// running light set that blocks without their own light commands inherit. +/// SetShadowOrigin is scoped to the block it appears in and never leaks across blocks. +/// +[] +type BlockPlan = { + /// The number of camera blocks in the buffer. + BlockCount: int + + /// The per-block plans, indexed by block (in buffer order). + Blocks: CameraBlockPlan[] + + /// The frame-default light set: every light command issued outside a camera block. + FrameDefaults: BlockLightSet +} + +// ───────────────────────────────────────────────────────────────────────────── +// Walk accumulators — the mutable state BlockPlan.build folds over. Reference +// records (one per open block per frame) so helpers mutate them in place; the +// immutable plan records above are the only data that leaves the module. +// ───────────────────────────────────────────────────────────────────────────── + +type private LightAccum = { + mutable Ambient: AmbientLight3D voption + DirLights: ResizeArray + PointLights: ResizeArray + SpotLights: ResizeArray +} + +type private BlockAccum = { + mutable HasLightCommands: bool + Lights: LightAccum + mutable ShadowOrigin: Vector3 voption + InitialCastEnabled: bool + StartIndex: int +} + +type private WalkState = { + Defaults: LightAccum + Running: LightAccum + Blocks: ResizeArray + mutable Current: BlockAccum voption + mutable CastEnabled: bool +} + +module private BlockPlanWalk = + + let inline lightAccum() : LightAccum = { + Ambient = ValueNone + DirLights = ResizeArray() + PointLights = ResizeArray() + SpotLights = ResizeArray() + } + + let inline applyLight (lights: LightAccum) (cmd: Command3D) = + match cmd with + | Command3D.SetAmbientLight a -> lights.Ambient <- ValueSome a + | Command3D.AddDirectionalLight d -> lights.DirLights.Add d + | Command3D.AddPointLight p -> lights.PointLights.Add p + | Command3D.AddSpotLight s -> lights.SpotLights.Add s + | _ -> () + + let inline snapshot(lights: LightAccum) : BlockLightSet = { + Ambient = lights.Ambient + DirLights = lights.DirLights.ToArray() + PointLights = lights.PointLights.ToArray() + SpotLights = lights.SpotLights.ToArray() + } + + let inline replaceContents (set: BlockLightSet) (accum: LightAccum) = + accum.Ambient <- set.Ambient + accum.DirLights.Clear() + accum.DirLights.AddRange(set.DirLights) + accum.PointLights.Clear() + accum.PointLights.AddRange(set.PointLights) + accum.SpotLights.Clear() + accum.SpotLights.AddRange(set.SpotLights) + + let inline merged (defaults: LightAccum) (own: LightAccum) : BlockLightSet = { + Ambient = + match own.Ambient with + | ValueSome _ -> own.Ambient + | ValueNone -> defaults.Ambient + DirLights = + Array.append (defaults.DirLights.ToArray()) (own.DirLights.ToArray()) + PointLights = + Array.append (defaults.PointLights.ToArray()) (own.PointLights.ToArray()) + SpotLights = + Array.append (defaults.SpotLights.ToArray()) (own.SpotLights.ToArray()) + } + + let inline finalizeBlock + (defaults: LightAccum) + (running: LightAccum) + (endIndex: int) + (block: BlockAccum) + : CameraBlockPlan = + let lights = + if block.HasLightCommands then + let final = merged defaults block.Lights + replaceContents final running + final + else + snapshot running + + { + HasLightCommands = block.HasLightCommands + Lights = lights + ShadowOrigin = block.ShadowOrigin + InitialCastEnabled = block.InitialCastEnabled + StartIndex = block.StartIndex + EndIndex = endIndex + } + + let closeCurrent (state: WalkState) (endIndex: int) = + match state.Current with + | ValueSome block -> + let plan = finalizeBlock state.Defaults state.Running endIndex block + state.Blocks.Add plan + state.Current <- ValueNone + | ValueNone -> () + + /// A BeginCamera while a block is open closes that block first (nested camera + /// commands are tolerated, matching the forward pass). + let beginBlock (state: WalkState) (startIndex: int) = + closeCurrent state (startIndex - 1) + + state.Current <- + ValueSome( + { + HasLightCommands = false + Lights = lightAccum() + ShadowOrigin = ValueNone + InitialCastEnabled = state.CastEnabled + StartIndex = startIndex + } + ) + + let lightCommand (state: WalkState) (cmd: Command3D) = + match state.Current with + | ValueSome block -> + block.HasLightCommands <- true + applyLight block.Lights cmd + | ValueNone -> + applyLight state.Defaults cmd + applyLight state.Running cmd + + let shadowOrigin (state: WalkState) (origin: Vector3) = + match state.Current with + | ValueSome block -> block.ShadowOrigin <- ValueSome origin + | ValueNone -> () + + let finish (state: WalkState) (endIndex: int) : BlockPlan = + closeCurrent state endIndex + + { + BlockCount = state.Blocks.Count + Blocks = state.Blocks.ToArray() + FrameDefaults = snapshot state.Defaults + } + +/// Builds a from a render buffer. +module BlockPlan = + + /// + /// The empty plan: no camera blocks, no frame defaults. Pipelines use it for single-camera + /// frames, which skip the buffer walk () + /// and its allocations entirely — every plan consumer is gated on the multi-block count. + /// + let empty: BlockPlan = { + BlockCount = 0 + Blocks = [||] + FrameDefaults = { + Ambient = ValueNone + DirLights = [||] + PointLights = [||] + SpotLights = [||] + } + } + + /// + /// Walks the buffer once and produces the per-camera-block light/shadow plan for the frame: + /// the block count, each block's final light set / shadow origin / initial shadow-caster + /// toggle / buffer slice, and the frame-default light set. + /// + let build(buffer: RenderBuffer3D) : BlockPlan = + let state: WalkState = { + Defaults = BlockPlanWalk.lightAccum() + Running = BlockPlanWalk.lightAccum() + Blocks = ResizeArray() + Current = ValueNone + CastEnabled = true + } + + for i = 0 to buffer.Count - 1 do + match buffer[i] with + | Command3D.BeginCamera _ + | Command3D.BeginCameraConfig _ -> BlockPlanWalk.beginBlock state (i + 1) + | Command3D.EndCamera -> BlockPlanWalk.closeCurrent state i + | Command3D.SetAmbientLight _ + | Command3D.AddDirectionalLight _ + | Command3D.AddPointLight _ + | Command3D.AddSpotLight _ as cmd -> BlockPlanWalk.lightCommand state cmd + | Command3D.SetShadowOrigin origin -> + BlockPlanWalk.shadowOrigin state origin + | Command3D.EnableShadows -> state.CastEnabled <- true + | Command3D.DisableShadows -> state.CastEnabled <- false + | _ -> () + + BlockPlanWalk.finish state buffer.Count diff --git a/src/Mibo.MonoGame/Graphics3D/Pipelines/ForwardPipeline.fs b/src/Mibo.MonoGame/Graphics3D/Pipelines/ForwardPipeline.fs index 3915acb..3ec04bc 100644 --- a/src/Mibo.MonoGame/Graphics3D/Pipelines/ForwardPipeline.fs +++ b/src/Mibo.MonoGame/Graphics3D/Pipelines/ForwardPipeline.fs @@ -14,7 +14,7 @@ open Mibo.Elmish.Graphics3D [] module private ForwardHelpers = - // LightBuffers + clearLights live in SceneData.fs; referenced here as Pipelines.LightBuffers. + // LightBuffers lives in SceneContext.fs; referenced here as Pipelines.LightBuffers. /// Maps a to the corresponding /// MonoGame (mirrors @@ -140,6 +140,119 @@ module private ForwardHelpers = part.PrimitiveCount ) +// ------------------------------------------------------------------ +// Per-camera-block light scoping +// ------------------------------------------------------------------ + +/// +/// Light-state transitions for multi-camera-block frames: applying one light command in-order, +/// loading a materialized light set into a live buffer set, and resetting the live buffers at a +/// camera block's start. +/// +module internal LightScoping = + + /// Applies one light command in-order: ambient overwrites; directional/point/spot append. + let inline apply (lights: LightBuffers) (cmd: Command3D) = + match cmd with + | Command3D.SetAmbientLight a -> lights.Ambient <- ValueSome a + | Command3D.AddDirectionalLight d -> lights.DirLights.Add d + | Command3D.AddPointLight p -> lights.PointLights.Add p + | Command3D.AddSpotLight s -> lights.SpotLights.Add s + | _ -> () + + /// Loads a materialized light set into a live buffer set, replacing its contents. + let inline loadSet (set: BlockLightSet) (lights: LightBuffers) = + lights.Ambient <- set.Ambient + lights.DirLights.Clear() + lights.DirLights.AddRange(set.DirLights) + lights.PointLights.Clear() + lights.PointLights.AddRange(set.PointLights) + lights.SpotLights.Clear() + lights.SpotLights.AddRange(set.SpotLights) + + /// + /// Applies one light command during the forward pass: to the live buffers always, and to the + /// frame defaults when no camera block is open — between-block commands update the defaults, + /// so a later block that resets sees them. + /// + let inline applyInOrder + (lights: LightBuffers) + (defaults: LightBuffers) + (inBlock: bool) + (cmd: Command3D) + = + apply lights cmd + + if not inBlock then + apply defaults cmd + + /// + /// Advances to the next camera block and, when that block carries its own light commands, + /// resets the live buffers to the frame defaults — the block's commands are applied in-order + /// as the forward loop reaches them. A block without light commands leaves the live buffers + /// untouched (inheritance). Returns whether the buffers were reset. + /// + let inline resetForBlock + (plan: BlockPlan) + (defaults: LightBuffers) + (lights: LightBuffers) + (blockIndex: byref) + : bool = + blockIndex <- blockIndex + 1 + + if plan.Blocks[blockIndex].HasLightCommands then + LightBuffers.copyInto defaults lights + true + else + false + + /// + /// Replays the buffer's camera and light commands the way the multi-camera-block forward + /// pass does — both buffers start empty, between-block commands accumulate into the + /// defaults, and each block resets (own commands) or inherits at its start — returning + /// every block's live light set at its close. Test hook pinning that live shading matches + /// the ; allocates a snapshot per + /// block, so it is not for the hot path. + /// + let replay + (buffer: RenderBuffer3D) + (plan: BlockPlan) + (lights: LightBuffers) + (defaults: LightBuffers) + : BlockLightSet[] = + let sets = ResizeArray(plan.BlockCount) + let mutable blockIndex = -1 + let mutable inBlock = false + + let closeBlock() = + if inBlock then + sets.Add { + Ambient = lights.Ambient + DirLights = lights.DirLights.ToArray() + PointLights = lights.PointLights.ToArray() + SpotLights = lights.SpotLights.ToArray() + } + + inBlock <- false + + for i = 0 to buffer.Count - 1 do + match buffer[i] with + | Command3D.BeginCamera _ + | Command3D.BeginCameraConfig _ -> + closeBlock() + resetForBlock plan defaults lights &blockIndex |> ignore + inBlock <- true + | Command3D.EndCamera -> closeBlock() + | Command3D.SetAmbientLight _ + | Command3D.AddDirectionalLight _ + | Command3D.AddPointLight _ + | Command3D.AddSpotLight _ as cmd -> + applyInOrder lights defaults inBlock cmd + | _ -> () + + closeBlock() + sets.ToArray() + // ── Where things live ── // MaterialKey / materialKey → PbrShading.fs (PBR handlers own the short-circuit) // PbrEffectParams + upload helpers (uploadLights/uploadMaterial/bindTextures) @@ -176,7 +289,11 @@ module private ForwardHelpers = /// /// Lighting budget: 1 ambient + 1 directional + up to 8 point + up to 4 spot lights, all bound /// to the PBR effect. Directional/point/spot shadows render to an R32F atlas -/// (DepthShadow.fx) and are sampled with manual 3×3 PCF. +/// (DepthShadow.fx) and are sampled with manual 3×3 PCF. In frames with more than one +/// camera block, lights are scoped per block: a block that issues light commands resets to the +/// frame defaults (light commands issued outside any camera block) plus its own commands, +/// applied in-order; a block without light commands inherits the previous block's set. +/// Single-camera frames gather lights frame-globally. /// /// /// Register via: @@ -192,7 +309,21 @@ type ForwardPipelineBase let atlasCfg = defaultArg shadowAtlas ShadowAtlasConfig.defaults let biasCfg = defaultArg shadowBias ShadowBiasConfig.defaults - let lights: Pipelines.LightBuffers = Pipelines.LightBuffers.defaults + // LightBuffers.create — LightBuffers.defaults is a shared module-level instance; two + // pipelines built from it would alias each other's light accumulators. + let lights: Pipelines.LightBuffers = Pipelines.LightBuffers.create 3 8 4 + + // Frame-default light set for multi-camera-block frames: rebuilt in-order by the forward + // pass each frame (between-block commands accumulate); a block that issues its own light + // commands resets the live buffers from this. + // LightBuffers.create — LightBuffers.defaults is a shared module-level instance. + let defaultLights: Pipelines.LightBuffers = + Pipelines.LightBuffers.create 3 8 4 + + // Scratch for a block's final light set (loaded from the block plan) when running that + // block's shadow pass — the live buffers trail the block's own in-order commands at block + // start, so the pass can't read them. + let blockLights: Pipelines.LightBuffers = Pipelines.LightBuffers.create 3 8 4 // PBR shading: the lazily-loaded PBR effect + params, the BasicEffect fallback, the instancing // effect + growable instance vertex buffer + staging, the MaterialKey short-circuit cache, and the @@ -213,6 +344,13 @@ type ForwardPipelineBase let mutable billboardEffect: BasicEffect voption = ValueNone let mutable lineEffect: BasicEffect voption = ValueNone + // Lazily-created unlit vertex-color effect + triangle for clearing a camera block's + // viewport region: gd.Clear ignores the viewport (D3D ClearRenderTargetView semantics), + // so a block clear is drawn as an NDC fullscreen triangle, which covers exactly the + // active viewport on every backend. + let mutable clearEffect: BasicEffect = null + let clearVerts = Array.zeroCreate 3 + // Post-process: a fullscreen quad created against the device on the first post-process frame. let mutable fullScreenQuad: FullScreenQuad voption = ValueNone @@ -667,16 +805,20 @@ type ForwardPipelineBase // ---------------------------------------------------------------- /// - /// Runs the shadow pass: collects dir + point + spot casters, renders depth to the atlas, then - /// uploads shadow uniforms to the PBR effect. The body lives in ShadowPass.run; this - /// member just forwards the pipeline's resources + config. Ensures the PBR effect is loaded - /// first (shadow uniforms upload to it). + /// Runs one shadow pass over a buffer slice: collects casters from [startIdx, endIdx), + /// renders depth to the atlas, then uploads shadow uniforms to the PBR effect. The body lives + /// in ShadowPass.run; this member just forwards the pipeline's resources + config. + /// Ensures the PBR effect is loaded first (shadow uniforms upload to it). /// member private this.runShadowPass ( gd: GraphicsDevice, - state: byref, buffer: RenderBuffer3D, + startIdx: int, + endIdx: int, + initialCastEnabled: bool, + passLights: Pipelines.LightBuffers, + camera: Camera3D, needsDepth: bool ) = // Ensure the PBR effect is loaded BEFORE the pass uploads shadow uniforms to it. @@ -687,13 +829,92 @@ type ForwardPipelineBase atlasCfg biasCfg shadowRes - lights + passLights pbrRes.Params buffer - state.CurrentCamera + startIdx + endIdx + initialCastEnabled + camera needsDepth |> fun r -> shadowRes.ShadowResult <- r // stash for the forward pass (Shade / user-effect scopes) + /// + /// Multi-camera-block block start: resets the live light buffers when the block carries its + /// own light commands (a block without any inherits them untouched), then renders this + /// block's shadow map — from the block's final light set, shadow origin, and buffer slice — + /// before any of its draws, and reseats the scene bundle's shadow state from the pass. + /// + member private this.beginShadowedBlock + ( + gd: GraphicsDevice, + buffer: RenderBuffer3D, + plan: BlockPlan, + blockIndex: byref, + camera: Camera3D, + scene: byref + ) = + if LightScoping.resetForBlock plan defaultLights lights &blockIndex then + pbrRes.LightsDirty <- true + + let block = plan.Blocks[blockIndex] + LightScoping.loadSet block.Lights blockLights + shadowRes.Origin <- block.ShadowOrigin + + this.runShadowPass( + gd, + buffer, + block.StartIndex, + block.EndIndex, + block.InitialCastEnabled, + blockLights, + camera, + false + ) + + scene.PointShadowSlots <- shadowRes.PointShadowSlots + scene.SpotShadowSlots <- shadowRes.SpotShadowSlots + scene.Shadows <- shadowRes.ShadowResult + + /// + /// Draws a solid-color NDC fullscreen triangle, which covers exactly the active viewport. + /// Used to clear a camera block's viewport region: gd.Clear ignores the viewport on + /// D3D-style backends (ClearRenderTargetView semantics), so an unclipped block clear + /// would wipe previously rendered camera blocks (split-screen). Color-only — the caller + /// saves/restores device state and leaves depth untouched. + /// + member private _.clearViewport (gd: GraphicsDevice) (c: Color) = + if obj.ReferenceEquals(clearEffect, null) then + clearEffect <- + new BasicEffect( + gd, + VertexColorEnabled = true, + LightingEnabled = false, + TextureEnabled = false + ) + + clearVerts[0] <- VertexPositionColor(Vector3(-1.f, -1.f, 0.f), c) + clearVerts[1] <- VertexPositionColor(Vector3(3.f, -1.f, 0.f), c) + clearVerts[2] <- VertexPositionColor(Vector3(-1.f, 3.f, 0.f), c) + + clearEffect.World <- Matrix.Identity + clearEffect.View <- Matrix.Identity + clearEffect.Projection <- Matrix.Identity + + gd.DepthStencilState <- DepthStencilState.None + gd.BlendState <- BlendState.Opaque + gd.RasterizerState <- RasterizerState.CullNone + + for pass in clearEffect.CurrentTechnique.Passes do + pass.Apply() + + gd.DrawUserPrimitives( + PrimitiveType.TriangleList, + clearVerts, + 0, + 1 + ) + // ---------------------------------------------------------------- // IRenderPipeline3D // ---------------------------------------------------------------- @@ -809,10 +1030,22 @@ type ForwardPipelineBase // (point-sampled depth for the manual 3×3 PCF); set a safe default here. gd.SamplerStates[5] <- SamplerState.PointClamp - // Pre-scan — capture camera, lights, shadow state, and post-process actions in one pass + // Pre-scan — capture camera, shadow state, and post-process actions in one pass. + // The block plan walks the buffer once for the per-camera-block light scoping; frames + // with more than one camera block scope lights per block, single-camera frames gather + // lights frame-globally below and skip the walk (and its allocations) entirely — the + // counter is maintained by the buffer on Add. Pipelines.LightBuffers.clear lights shadowRes.Origin <- ValueNone + let multiBlock = buffer.CameraBlockCount > 1 + + let plan = + if multiBlock then + BlockPlan.build buffer + else + BlockPlan.empty + let mutable state: ForwardState = { HasCamera = false View = Matrix.Identity @@ -832,8 +1065,10 @@ type ForwardPipelineBase else ValueNone - // Pre-scan: lights, camera, and shadow commands (shadow origin / toggle) need to be - // known before the shadow pass runs. Draw commands are handled in the forward pass. + // Pre-scan: camera and shadow commands (shadow origin / toggle) need to be known before + // the shadow pass runs. Single-camera frames also gather lights frame-globally here — + // multi-block frames scope lights per camera block in the forward pass instead. + // Draw commands are handled in the forward pass. for i = 0 to buffer.Count - 1 do match buffer[i] with | Command3D.BeginCamera cam -> @@ -852,10 +1087,12 @@ type ForwardPipelineBase state.CurrentCamera <- cfg.Camera state.CurrentConfig <- ValueSome cfg - | Command3D.SetAmbientLight a -> lights.Ambient <- ValueSome a - | Command3D.AddDirectionalLight d -> lights.DirLights.Add d - | Command3D.AddPointLight p -> lights.PointLights.Add p - | Command3D.AddSpotLight s -> lights.SpotLights.Add s + | Command3D.SetAmbientLight _ + | Command3D.AddDirectionalLight _ + | Command3D.AddPointLight _ + | Command3D.AddSpotLight _ as cmd -> + if not multiBlock then + LightScoping.apply lights cmd | Command3D.SetShadowOrigin origin -> shadowRes.Origin <- ValueSome origin | Command3D.PostProcess action @@ -865,17 +1102,39 @@ type ForwardPipelineBase | ValueNone -> () | _ -> () + // Multi-camera-block frames: start the persistent defaults empty — the forward pass + // builds them in-order (between-block commands accumulate; each block resets to the + // defaults-so-far or inherits the running set at its BeginCamera), so live shading + // matches the block plan by construction. + if multiBlock then + Pipelines.LightBuffers.clear defaultLights + // Per-block shadow passes reseat ShadowResult at each block start; don't leak last + // frame's result into a DrawImmediate before the first block. + shadowRes.ShadowResult <- ValueNone + // Scene depth is needed when at least one PostProcessWithDepth action exists. Pass that to // the shadow pass so it collects opaque geometry even without a shadow-casting light. let needsDepth = buffer.DepthPostProcessCount > 0 - // Shadow pass (also collects geometry for scene-depth when needsDepth is true) - if state.HasCamera then - this.runShadowPass(gd, &state, buffer, needsDepth) + // Shadow pass: single-camera frames run one pass up front (also collects geometry for + // scene-depth when needsDepth is true). Multi-block frames run one pass per camera block + // at its BeginCamera/BeginCameraConfig in the forward loop instead. + if state.HasCamera && not multiBlock then + this.runShadowPass( + gd, + buffer, + 0, + buffer.Count, + true, + lights, + state.CurrentCamera, + needsDepth + ) // Forward pass - // Lights + shadow state are already gathered; the camera is re-established per block - // below. activeEffect tracks the per-group shading scope (beginEffect/endEffect): + // Lights are seeded (frame-global for single-camera frames, the frame defaults for + // multi-block frames) and shadow state is gathered; the camera is re-established per + // block below. activeEffect tracks the per-group shading scope (beginEffect/endEffect): // ValueNone → default PBR path; ValueSome e → shade with the user effect. Scopes do NOT // persist across cameras — a new camera block (BeginCamera/BeginCameraConfig) and EndCamera // both reset it, so a forgotten endEffect can't leak a user effect into the next view. @@ -900,6 +1159,10 @@ type ForwardPipelineBase // skipped. So reset to "no active camera" before the forward loop. state.HasCamera <- false + // Running camera-block index into the block plan; advanced at each + // BeginCamera/BeginCameraConfig below (multi-block frames only). + let mutable blockIndex = -1 + // When post-process commands are present, render the forward pass to an offscreen target // so each action can sample the scene texture. Otherwise render direct to the back-buffer. let usePostProcess = buffer.PostProcessCount > 0 @@ -938,6 +1201,18 @@ type ForwardPipelineBase // New camera block: scopes don't persist across cameras. activeEffect <- ValueNone + // Multi-block frames: reset-or-inherit the lights, then render this block's + // shadow map before any of its draws. + if multiBlock then + this.beginShadowedBlock( + gd, + buffer, + plan, + &blockIndex, + state.CurrentCamera, + &scene + ) + | Command3D.BeginCameraConfig cfg -> // Apply viewport + clear color (deferred from pre-scan so clearing happens here). match cfg.Viewport with @@ -962,12 +1237,39 @@ type ForwardPipelineBase (float32 vp.Height) match cfg.ClearColor with - | ValueSome c -> gd.Clear(ClearOptions.Target, c.ToVector4(), 1.0f, 0) + | ValueSome c -> + match cfg.Viewport with + | ValueSome _ -> + // gd.Clear ignores the viewport, so the block clear is drawn: an NDC + // fullscreen triangle covers exactly the active viewport. Color-only — + // depth is untouched, matching the fullscreen gd.Clear path below. + let prevDepth = gd.DepthStencilState + let prevBlend = gd.BlendState + let prevRaster = gd.RasterizerState + + this.clearViewport gd c + + gd.DepthStencilState <- prevDepth + gd.BlendState <- prevBlend + gd.RasterizerState <- prevRaster + | ValueNone -> gd.Clear(ClearOptions.Target, c.ToVector4(), 1.0f, 0) | ValueNone -> () // New camera block: scopes don't persist across cameras. activeEffect <- ValueNone + // Multi-block frames: reset-or-inherit the lights, then render this block's + // shadow map before any of its draws. + if multiBlock then + this.beginShadowedBlock( + gd, + buffer, + plan, + &blockIndex, + state.CurrentCamera, + &scene + ) + | Command3D.EndCamera -> if state.HasCamera then // Restore fullscreen viewport + mark camera inactive so subsequent draws are skipped @@ -1015,11 +1317,17 @@ type ForwardPipelineBase if state.HasCamera then this.handleDrawLine3D(gd, &state, s, f, color) - // ── Lighting (already consumed in pre-scan; no-op here) ── + // ── Lighting ── + // Multi-block frames apply light commands in-order (a mid-block command affects only + // subsequent draws; between-block commands also update the frame defaults). Single-camera + // frames gathered lights frame-globally in the pre-scan — no-op here. | Command3D.SetAmbientLight _ | Command3D.AddDirectionalLight _ | Command3D.AddPointLight _ - | Command3D.AddSpotLight _ -> () + | Command3D.AddSpotLight _ as cmd -> + if multiBlock then + LightScoping.applyInOrder lights defaultLights state.HasCamera cmd + pbrRes.LightsDirty <- true // ── Shadow state (consumed in the shadow pass; no-op here) ── | Command3D.SetShadowOrigin _ @@ -1054,8 +1362,9 @@ type ForwardPipelineBase state.HasCamera <- savedHasCamera // ── Scene depth pre-pass (camera-POV, reusing collected geometry) ── - // Only runs when PostProcessWithDepth actions exist. The shadow pass already collected the - // opaque geometry; renderSceneDepth re-renders it from the camera VP into an R32F target. + // Only runs when PostProcessWithDepth actions exist. Single-camera frames reuse the + // geometry the up-front shadow pass collected; multi-block frames re-collect the full + // buffer below. renderSceneDepth re-renders it from the camera VP into an R32F target. let sceneDepth: RenderTarget2D voption = if needsDepth && usePostProcess then // Ensure the depth target matches the back-buffer size. @@ -1083,8 +1392,18 @@ type ForwardPipelineBase ) ) + // Multi-block frames collected geometry per block (each block's shadow pass saw only + // its own slice); re-collect the full buffer once so scene depth keeps the + // union-of-all-geometry behavior. Safe to overwrite the pooled arrays here — every + // per-block shadow pass has already rendered. The depth effect may never have loaded + // if no block had casters. + if multiBlock then + ShadowPass.ensureDepthEffect gd shadowRes + ShadowPass.collectGeometry buffer 0 buffer.Count true shadowRes + // Reuse the camera VP the forward pass computed (correct viewport aspect). The forward - // pass captured it in state.View * state.Projection during BeginCamera. + // pass captured it in state.View * state.Projection during BeginCamera — in multi-block + // frames that is the LAST block's camera. match shadowRes.Effect, shadowRes.Params, sceneDepthRT with | ValueSome eff, ValueSome prms, ValueSome rt -> ShadowPass.renderSceneDepth diff --git a/src/Mibo.MonoGame/Graphics3D/Pipelines/PbrShading.fs b/src/Mibo.MonoGame/Graphics3D/Pipelines/PbrShading.fs index 6c5b8bf..8d55aee 100644 --- a/src/Mibo.MonoGame/Graphics3D/Pipelines/PbrShading.fs +++ b/src/Mibo.MonoGame/Graphics3D/Pipelines/PbrShading.fs @@ -44,20 +44,29 @@ type internal MaterialKey = { } /// Per-frame scene state the PBR handlers read (passed byref, no allocation). +/// +/// is frame-global in +/// single-camera frames; in frames with more than one camera block it is scoped to the block +/// currently being drawn (reset-with-inheritance — see +/// ). +/// [] type ForwardFrame = { - /// The frame's accumulated lights. + /// The active light set (see type remarks). Lights: LightBuffers /// The pooled bone-palette scratch (shared with the shadow pass) for skinned draws. BonePaletteScratch: Matrix[] - /// Per-light shadow atlas slots (-1 = no shadow), indexed by PointLights position. - PointShadowSlots: int[] - /// Per-light shadow atlas slots (-1 = no shadow), indexed by SpotLights position. - SpotShadowSlots: int[] - /// The frame's shadow pass output — ValueNone when no shadow-casting light / missing DepthShadow.fx. + /// Per-light shadow atlas slots (-1 = no shadow), indexed by PointLights position. + /// Reseated from the shadow pass output at each camera block's start. + mutable PointShadowSlots: int[] + /// Per-light shadow atlas slots (-1 = no shadow), indexed by SpotLights position. + /// Reseated from the shadow pass output at each camera block's start. + mutable SpotShadowSlots: int[] + /// The active shadow pass output — ValueNone when no shadow-casting light / missing DepthShadow.fx. + /// Reseated from the shadow pass output at each camera block's start. /// The user-effect scope () /// uploads these uniforms by name so a custom effect can opt into shadow sampling. - Shadows: ShadowResult voption + mutable Shadows: ShadowResult voption /// The total elapsed game time, in seconds (Game.TotalGameTime.TotalSeconds). Uploaded /// as the time uniform so an animated shader (water ripples, flowing textures, pulsing emissive) /// has a clock to read — the only animation input the scene-data contract provides. diff --git a/src/Mibo.MonoGame/Graphics3D/Pipelines/SceneData.fs b/src/Mibo.MonoGame/Graphics3D/Pipelines/SceneData.fs deleted file mode 100644 index 73191c5..0000000 --- a/src/Mibo.MonoGame/Graphics3D/Pipelines/SceneData.fs +++ /dev/null @@ -1,123 +0,0 @@ -namespace Mibo.Elmish.Graphics3D.Pipelines - -open Microsoft.Xna.Framework -open Microsoft.Xna.Framework.Graphics -open Mibo.Elmish -open Mibo.Elmish.Graphics3D - -// ───────────────────────────────────────────────────────────────────────────── -// ForwardState — per-frame forward-rendering state, threaded byref through dispatch. -// -// Mirrors the RendererState pattern from Renderer2D.fs: a mutable struct threaded by reference so -// dispatch avoids heap allocation on the hot path. Public because the staged base's virtual Shade -// exposes it (byref) to subclass / object-expression overrides — a shading strategy needs the -// active camera's view/projection. Repopulated each frame by the gather + forward-pass; overrides -// read it, they should not mutate it. -// ───────────────────────────────────────────────────────────────────────────── - -/// Per-frame forward-rendering state, threaded byref through dispatch. -/// Mutable struct (hot path, no allocation); repopulated each frame by the forward pass. -[] -type ForwardState = { - mutable HasCamera: bool - mutable View: Matrix - mutable Projection: Matrix - mutable CurrentCamera: Camera3D - mutable CurrentConfig: Camera3DConfig voption - mutable SavedViewport: Viewport -} - -// ───────────────────────────────────────────────────────────────────────────── -// SceneData — the reusable per-frame scene gather. -// -// LightBuffers + ShadowResult moved to SceneContext.fs (compiled before Command3D.fs) -// so the DrawImmediate callback can carry a SceneContext that references them. -// This file keeps the per-frame gather record + ForwardState. -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// The per-frame gather of scene-global state that the shadow pass and the shading -/// pass both need: the active camera's view/projection, the accumulated lights, and the -/// shadow origin override. -/// -/// -/// Populated once per frame by -/// walking the command buffer. The -/// instance is owned by the caller (reused across frames — no per-frame allocation); gather -/// clears and repopulates it. -/// -[] -type SceneData = { - /// Whether a camera command was seen this frame (gates shadow/forward passes). - mutable HasCamera: bool - - /// The active camera's view matrix. - mutable View: Matrix - - /// The active camera's projection matrix (aspect-corrected in the forward pass). - mutable Projection: Matrix - - /// The active camera config. - mutable CurrentCamera: Camera3D - - /// The camera config (viewport/clear/post-process), if a BeginCameraConfig was used. - mutable CurrentConfig: Camera3DConfig voption - - /// The accumulated lights (owned by the caller; cleared + repopulated here). - Lights: LightBuffers - - /// The frame's shadow-origin override, if SetShadowOrigin was issued. - mutable ShadowOrigin: Vector3 voption -} - -/// The reusable scene-gather: walks the buffer once for camera + lights + shadow origin. -module SceneData = - - /// - /// Clears the lights and gathers the scene-global state from the buffer: the active camera - /// (BeginCamera/BeginCameraConfig — last one wins), all lights, and the shadow origin. - /// Draw commands are ignored (they're handled in the forward pass). - /// - /// Camera → (view, projection). Passed in so this module has no - /// dependency on the matrix-construction convention (lives in ForwardPipeline/ForwardPipelineBase). - /// The caller-owned gather record (Lights is reused across frames). - let gather - (buildMatrices: Camera3D -> struct (Matrix * Matrix)) - (data: byref) - (buffer: RenderBuffer3D) - : unit = - LightBuffers.clear data.Lights - data.HasCamera <- false - data.View <- Matrix.Identity - data.Projection <- Matrix.Identity - data.CurrentCamera <- Unchecked.defaultof - data.CurrentConfig <- ValueNone - data.ShadowOrigin <- ValueNone - - for i = 0 to buffer.Count - 1 do - match buffer[i] with - | Command3D.BeginCamera cam -> - let struct (v, p) = buildMatrices cam - data.HasCamera <- true - data.View <- v - data.Projection <- p - data.CurrentCamera <- cam - data.CurrentConfig <- ValueNone - - | Command3D.BeginCameraConfig cfg -> - let struct (v, p) = buildMatrices cfg.Camera - data.HasCamera <- true - data.View <- v - data.Projection <- p - data.CurrentCamera <- cfg.Camera - data.CurrentConfig <- ValueSome cfg - - | Command3D.SetAmbientLight a -> data.Lights.Ambient <- ValueSome a - | Command3D.AddDirectionalLight d -> data.Lights.DirLights.Add d - | Command3D.AddPointLight p -> data.Lights.PointLights.Add p - | Command3D.AddSpotLight s -> data.Lights.SpotLights.Add s - | Command3D.SetShadowOrigin origin -> - data.ShadowOrigin <- ValueSome origin - | _ -> () - -// ShadowResult + SceneContext moved to SceneContext.fs (compiled before Command3D.fs). diff --git a/src/Mibo.MonoGame/Graphics3D/Pipelines/ShadowPass.fs b/src/Mibo.MonoGame/Graphics3D/Pipelines/ShadowPass.fs index 9954799..a362177 100644 --- a/src/Mibo.MonoGame/Graphics3D/Pipelines/ShadowPass.fs +++ b/src/Mibo.MonoGame/Graphics3D/Pipelines/ShadowPass.fs @@ -158,6 +158,12 @@ type ShadowResources(atlasCfg: ShadowAtlasConfig, biasCfg: ShadowBiasConfig) = /// Per-light shadow slot mapping for spot lights; -1 = no shadow. member val SpotShadowSlots: int[] = [||] with get, set + /// Grow-only scratch for save/restore of the caller's render-target bindings around + /// a shadow/depth pass — avoids a GetRenderTargets() array allocation per pass. + /// Resized only when the bound count changes; used sequentially (atlas pass, then depth + /// pre-pass), never re-entrantly. + member val RenderTargetScratch: RenderTargetBinding[] = [||] with get, set + /// Pooled scratch for the multi-caster shadowViewProjs upload. member val ViewProjsScratch = Array.zeroCreate 16 with get, set @@ -381,14 +387,21 @@ module internal ShadowPass = ) /// - /// Scans the command buffer once and collects every opaque draw into the pooled arrays on - /// , recording a CastsShadow flag per entry (snapshot of the - /// EnableShadows/DisableShadows state when the draw was emitted). Shared by the - /// shadow render (filters to CastsShadow = true) and the scene-depth render (all entries), - /// so shadow + depth never re-scan the buffer. Stashes the four counts on . + /// Scans the buffer range [startIdx, endIdx) and collects every opaque draw into the + /// pooled arrays on , recording a CastsShadow flag per entry + /// (snapshot of the EnableShadows/DisableShadows state when the draw was emitted, + /// starting from ). Shared by the shadow render (filters + /// to CastsShadow = true) and the scene-depth render (all entries), so shadow + depth + /// never re-scan the buffer. Stashes the four counts on . /// - let collectGeometry (buffer: RenderBuffer3D) (res: ShadowResources) = - let mutable castEnabled = true + let collectGeometry + (buffer: RenderBuffer3D) + (startIdx: int) + (endIdx: int) + (initialCastEnabled: bool) + (res: ShadowResources) + = + let mutable castEnabled = initialCastEnabled let mutable drawCount = 0 let mutable skinnedCount = 0 let mutable instancedCount = 0 @@ -398,7 +411,7 @@ module internal ShadowPass = let mutable shadowInstancedDraws = res.InstancedDraws let mutable shadowModelPartDraws = res.ModelPartDraws - for i = 0 to buffer.Count - 1 do + for i = startIdx to endIdx - 1 do match buffer[i] with | Command3D.EnableShadows -> castEnabled <- true | Command3D.DisableShadows -> castEnabled <- false @@ -725,6 +738,24 @@ module internal ShadowPass = // High-level passes // ───────────────────────────────────────────────────────────────────────────── + /// + /// Saves the caller's render-target bindings into the pooled scratch (resized only when the + /// bound count changes) — avoids GetRenderTargets()'s per-call array allocation, which + /// would otherwise happen once per shadow/depth pass (N per multi-camera-block frame). + /// Restore with SetRenderTargets on the returned array. + /// + let saveRenderTargets + (gd: GraphicsDevice) + (res: ShadowResources) + : RenderTargetBinding[] = + let count = gd.RenderTargetCount + + if res.RenderTargetScratch.Length <> count then + res.RenderTargetScratch <- Array.zeroCreate count + + gd.GetRenderTargets(res.RenderTargetScratch) + res.RenderTargetScratch + /// /// Renders collected casters into the shadow atlas from each registered light's view-projection. /// Filters to CastsShadow = true entries and frustum-culls per caster (primitives only; @@ -758,6 +789,9 @@ module internal ShadowPass = let prevRaster = gd.RasterizerState let prevBlend = gd.BlendState let prevDepth = gd.DepthStencilState + // Restore the caller's bindings, not the back-buffer: under post-processing this pass + // runs interleaved while the scene render target is bound. + let prevTargets = saveRenderTargets gd res gd.SetRenderTarget(res.Atlas.Fbo) @@ -828,7 +862,7 @@ module internal ShadowPass = instancedCount (fun d -> instancedDraws[d].CastsShadow) - gd.SetRenderTarget null + gd.SetRenderTargets prevTargets gd.Viewport <- prevViewport gd.RasterizerState <- prevRaster gd.BlendState <- prevBlend @@ -852,6 +886,8 @@ module internal ShadowPass = let prevRaster = gd.RasterizerState let prevBlend = gd.BlendState let prevDepth = gd.DepthStencilState + // Restore the caller's bindings, not the back-buffer (see renderAtlasCasters). + let prevTargets = saveRenderTargets gd res gd.SetRenderTarget depthTarget // 1.0 = far: uncovered pixels (skybox, gaps) read as far so post-process fog treats them as @@ -907,7 +943,7 @@ module internal ShadowPass = res.CollectedInstancedCount (fun _ -> true) - gd.SetRenderTarget null + gd.SetRenderTargets prevTargets gd.Viewport <- prevViewport gd.RasterizerState <- prevRaster gd.BlendState <- prevBlend @@ -930,23 +966,11 @@ module internal ShadowPass = res.Atlas.Clear() let mutable casterSlot = 0 - // Directional caster first (slot 0 by convention). - let hasDirCaster = - let mutable found = false - - for i = 0 to lights.DirLights.Count - 1 do - if lights.DirLights[i].CastsShadows then - found <- true - - found - - if hasDirCaster then - let mutable dirIdx = 0 - - while not lights.DirLights[dirIdx].CastsShadows do - dirIdx <- dirIdx + 1 - - let dirLight = lights.DirLights[dirIdx] + // Directional caster first (slot 0 by convention). Only the first directional light is + // shaded, so only it can cast — a non-casting DirLights[0] means no directional caster, + // even if a later directional light has CastsShadows set. + if lights.DirLights.Count > 0 && lights.DirLights[0].CastsShadows then + let dirLight = lights.DirLights[0] let dir = Conversions.fromNumericsVector3 dirLight.Direction let vp = buildDirectionalViewProj atlasCfg res.Origin dir activeCamera @@ -1089,11 +1113,23 @@ module internal ShadowPass = // Orchestrator // ───────────────────────────────────────────────────────────────────────────── + /// Loads the DepthShadow effect on first use (no-op once loaded). + let ensureDepthEffect (gd: GraphicsDevice) (res: ShadowResources) = + match res.Effect, res.Params with + | ValueSome _, ValueSome _ -> () + | _ -> + match ShaderLoader.loadEffect gd "DepthShadow" with + | ValueSome e -> + res.Params <- ValueSome(buildShadowParams e) + res.Effect <- ValueSome e + | ValueNone -> () + /// - /// Runs the shadow pass: scans lights, collects opaque geometry once (shared with scene-depth), - /// registers casters into the atlas, renders depth, and uploads shadow uniforms to the PBR - /// effect. When is true, geometry is collected even without a - /// shadow-casting light so renderSceneDepth can reuse it. + /// Runs the shadow pass: scans lights, collects opaque geometry from the buffer range + /// [startIdx, endIdx) (shared with scene-depth), registers casters into the atlas, + /// renders depth, and uploads shadow uniforms to the PBR effect. When + /// is true, geometry is collected even without a shadow-casting light so renderSceneDepth + /// can reuse it. Only the first directional light can cast (it is the one the shader lights with). /// let run (gd: GraphicsDevice) @@ -1103,18 +1139,19 @@ module internal ShadowPass = (lights: LightBuffers) (pbrParams: PbrEffectParams voption) (buffer: RenderBuffer3D) + (startIdx: int) + (endIdx: int) + (initialCastEnabled: bool) (activeCamera: Camera3D) (needsDepth: bool) : ShadowResult voption = // ── Scan lights for casters ── - let mutable hasDirCaster = false + let hasDirCaster = + lights.DirLights.Count > 0 && lights.DirLights[0].CastsShadows + let mutable hasPointCaster = false let mutable hasSpotCaster = false - for i = 0 to lights.DirLights.Count - 1 do - if lights.DirLights[i].CastsShadows then - hasDirCaster <- true - for i = 0 to lights.PointLights.Count - 1 do if lights.PointLights[i].CastsShadows then hasPointCaster <- true @@ -1138,7 +1175,7 @@ module internal ShadowPass = // ── Collect opaque geometry ONCE (shared by shadow render + scene-depth render) ── if hasAnyCaster || needsDepth then - collectGeometry buffer res + collectGeometry buffer startIdx endIdx initialCastEnabled res // ── Shadow pass (only when a shadow-casting light exists) ── if not hasAnyCaster then @@ -1150,28 +1187,13 @@ module internal ShadowPass = // renderSceneDepth can run (fog/DoF/SSOA in a shadowless scene). Without this the effect // is never loaded and every postProcessWithDepth action silently receives Depth = None. if needsDepth then - match res.Effect, res.Params with - | ValueSome _, ValueSome _ -> () - | _ -> - match ShaderLoader.loadEffect gd "DepthShadow" with - | ValueSome e -> - res.Params <- ValueSome(buildShadowParams e) - res.Effect <- ValueSome e - | ValueNone -> () + ensureDepthEffect gd res ValueNone else res.Atlas.EnsureResources gd - // Load DepthShadow effect on first use. - match res.Effect, res.Params with - | ValueSome _, ValueSome _ -> () - | _ -> - match ShaderLoader.loadEffect gd "DepthShadow" with - | ValueSome e -> - res.Params <- ValueSome(buildShadowParams e) - res.Effect <- ValueSome e - | ValueNone -> () + ensureDepthEffect gd res match res.Effect, res.Params with | ValueSome depthEffect, ValueSome depthParams -> diff --git a/src/Mibo.MonoGame/Graphics3D/RenderBuffer3D.fs b/src/Mibo.MonoGame/Graphics3D/RenderBuffer3D.fs index 9c4e221..5c3813b 100644 --- a/src/Mibo.MonoGame/Graphics3D/RenderBuffer3D.fs +++ b/src/Mibo.MonoGame/Graphics3D/RenderBuffer3D.fs @@ -29,6 +29,7 @@ type RenderBuffer3D([] ?capacity: int) = let mutable clearCounter = 0 let mutable postProcessCount = 0 let mutable depthPostProcessCount = 0 + let mutable cameraBlockCount = 0 let ensureCapacity(needed: int) = if count + needed > items.Length then @@ -61,6 +62,13 @@ type RenderBuffer3D([] ?capacity: int) = /// member _.DepthPostProcessCount = depthPostProcessCount + /// + /// Number of BeginCamera/BeginCameraConfig commands added since the last + /// Clear. Lets a pipeline skip the per-camera-block plan (and its allocations) for + /// single-camera frames. + /// + member _.CameraBlockCount = cameraBlockCount + /// Gets the command at the specified index. member _.Item(i: int) = items[i] @@ -74,6 +82,8 @@ type RenderBuffer3D([] ?capacity: int) = | Command3D.PostProcessWithDepth _ -> postProcessCount <- postProcessCount + 1 depthPostProcessCount <- depthPostProcessCount + 1 + | Command3D.BeginCamera _ + | Command3D.BeginCameraConfig _ -> cameraBlockCount <- cameraBlockCount + 1 | _ -> () count <- count + 1 @@ -93,6 +103,7 @@ type RenderBuffer3D([] ?capacity: int) = count <- 0 postProcessCount <- 0 depthPostProcessCount <- 0 + cameraBlockCount <- 0 // Periodically zero the backing array so stale managed refs (Model/Texture2D/Effect) // in slots above count don't keep unloaded assets alive indefinitely after a scene // shrinks or chunks evict. ~5s at 60fps; Array.Clear on structs is a cheap memset. diff --git a/src/Mibo.MonoGame/Graphics3D/SceneContext.fs b/src/Mibo.MonoGame/Graphics3D/SceneContext.fs index ddf94e2..80a68b1 100644 --- a/src/Mibo.MonoGame/Graphics3D/SceneContext.fs +++ b/src/Mibo.MonoGame/Graphics3D/SceneContext.fs @@ -6,15 +6,14 @@ open Mibo.Elmish open Mibo.Elmish.Graphics3D // ───────────────────────────────────────────────────────────────────────────── -// Early-defined scene types (LightBuffers, ShadowResult, SceneContext). +// Early-defined scene types (LightBuffers, ShadowResult, SceneContext, ForwardState). // // These live in their own file, compiled BEFORE Command3D.fs, because the // DrawImmediate command's callback carries a SceneContext — and SceneContext -// references LightBuffers + ShadowResult. The rest of the scene gather (the -// SceneData record, SceneData.gather, ForwardState) stays in Pipelines/SceneData.fs; -// only the types a public callback signature needs are hoisted here. +// references LightBuffers + ShadowResult. ForwardState shares the file so the +// pipeline files (compiled later) can thread it byref. // -// Same namespace as SceneData.fs (Mibo.Elmish.Graphics3D.Pipelines) so existing +// Same namespace as the pipelines (Mibo.Elmish.Graphics3D.Pipelines) so existing // references resolve unchanged — a namespace can span multiple files. // ───────────────────────────────────────────────────────────────────────────── @@ -24,18 +23,25 @@ open Mibo.Elmish.Graphics3D /// /// /// Public so custom pipelines and the shadow pass can read the gathered lights. +/// +/// In single-camera frames the accumulator holds every light command in the buffer, +/// frame-globally. In frames with more than one camera block the lights are scoped per +/// block: a block that issues its own light commands resets the accumulator to the frame +/// defaults (the light commands issued outside any camera block) and applies its own +/// commands in-order; a block that issues none inherits the previous block's set. +/// /// type LightBuffers = { - /// Ambient light for the frame (single slot). + /// Ambient light for the active camera block (single slot). mutable Ambient: AmbientLight3D voption - /// Directional lights accumulated this frame. + /// Directional lights accumulated for the active camera block. DirLights: ResizeArray - /// Point lights accumulated this frame. + /// Point lights accumulated for the active camera block. PointLights: ResizeArray - /// Spot lights accumulated this frame. + /// Spot lights accumulated for the active camera block. SpotLights: ResizeArray } @@ -56,6 +62,7 @@ module LightBuffers = } /// Default-capacity empty accumulator (3 dir / 8 point / 4 spot). + [] let defaults: LightBuffers = create 3 8 4 /// Resets all light accumulators to empty. @@ -65,6 +72,16 @@ module LightBuffers = lights.PointLights.Clear() lights.SpotLights.Clear() + /// Copies the contents of into , replacing whatever target held. + let inline copyInto (source: LightBuffers) (target: LightBuffers) = + target.Ambient <- source.Ambient + target.DirLights.Clear() + target.DirLights.AddRange(source.DirLights) + target.PointLights.Clear() + target.PointLights.AddRange(source.PointLights) + target.SpotLights.Clear() + target.SpotLights.AddRange(source.SpotLights) + // ───────────────────────────────────────────────────────────────────────────── // ShadowResult — the shadow pass output, threaded to both Shade overrides and // SceneUpload so a custom/user effect can opt into shadow sampling by name. @@ -146,7 +163,8 @@ type SceneContext = { /// The active camera config. Camera: Camera3D - /// The frame's accumulated lights (ambient + directional + point + spot). + /// The active light set (ambient + directional + point + spot). Frame-global in + /// single-camera frames; scoped to the current camera block in multi-camera-block frames. Lights: LightBuffers /// The frame's shadow pass output — ValueNone when no shadow-casting light / missing DepthShadow.fx. @@ -155,3 +173,25 @@ type SceneContext = { /// Total elapsed game time, in seconds — the animation clock. Time: float32 } + +// ───────────────────────────────────────────────────────────────────────────── +// ForwardState — per-frame forward-rendering state, threaded byref through dispatch. +// +// Mirrors the RendererState pattern from Renderer2D.fs: a mutable struct threaded by reference so +// dispatch avoids heap allocation on the hot path. Public because the staged base's virtual Shade +// exposes it (byref) to subclass / object-expression overrides — a shading strategy needs the +// active camera's view/projection. Repopulated each frame by the gather + forward-pass; overrides +// read it, they should not mutate it. +// ───────────────────────────────────────────────────────────────────────────── + +/// Per-frame forward-rendering state, threaded byref through dispatch. +/// Mutable struct (hot path, no allocation); repopulated each frame by the forward pass. +[] +type ForwardState = { + mutable HasCamera: bool + mutable View: Matrix + mutable Projection: Matrix + mutable CurrentCamera: Camera3D + mutable CurrentConfig: Camera3DConfig voption + mutable SavedViewport: Viewport +} diff --git a/src/Mibo.MonoGame/Mibo.MonoGame.fsproj b/src/Mibo.MonoGame/Mibo.MonoGame.fsproj index 9b7fe78..fe4a50d 100644 --- a/src/Mibo.MonoGame/Mibo.MonoGame.fsproj +++ b/src/Mibo.MonoGame/Mibo.MonoGame.fsproj @@ -40,7 +40,7 @@ - + diff --git a/src/Mibo.MonoGame/Runtime.fs b/src/Mibo.MonoGame/Runtime.fs index cecdbc7..9bdfeaf 100644 --- a/src/Mibo.MonoGame/Runtime.fs +++ b/src/Mibo.MonoGame/Runtime.fs @@ -59,6 +59,16 @@ type MiboGame<'Model, 'Msg>(mgProgram: MonoGameProgram<'Model, 'Msg>) as this = graphics.PreferredBackBufferWidth <- config.Width graphics.PreferredBackBufferHeight <- config.Height + // Keep backbuffer contents across mid-frame render-target switches. The 3D + // pipelines rebind the backbuffer after offscreen passes (shadow atlas, scene + // RT for post-processing); on backends that honor DiscardContents at rebind + // (DX12-native), everything drawn before the switch — the frame clear, earlier + // camera blocks — is wiped. Registered before DeviceConfig callbacks so a game + // can still override. + graphics.PreparingDeviceSettings.AddHandler(fun _ args -> + args.GraphicsDeviceInformation.PresentationParameters.RenderTargetUsage <- + RenderTargetUsage.PreserveContents) + this.Window.Title <- config.Title this.IsMouseVisible <- true diff --git a/src/Mibo.Raylib.Tests/BlockPlanTests.fs b/src/Mibo.Raylib.Tests/BlockPlanTests.fs new file mode 100644 index 0000000..ee403b2 --- /dev/null +++ b/src/Mibo.Raylib.Tests/BlockPlanTests.fs @@ -0,0 +1,535 @@ +module Mibo.Raylib.Tests.BlockPlanTests + +open System.Numerics +open Expecto +open Raylib_cs +open Mibo.Elmish +open Mibo.Elmish.Graphics3D +open Mibo.Elmish.Graphics3D.Pipelines + +let private cam = Unchecked.defaultof + +let private planOf(cmds: Command3D list) = + use buffer = new RenderBuffer3D() + + for cmd in cmds do + buffer.Add cmd + + BlockPlan.build buffer + +/// Builds the plan AND replays the buffer the way the multi-camera-block forward pass +/// reconstructs live light sets (no FrameDefaults seeding — regression hook for the +/// live-shading divergence found in PR #89 review). +let private replayOf(cmds: Command3D list) = + use buffer = new RenderBuffer3D() + + for cmd in cmds do + buffer.Add cmd + + let plan = BlockPlan.build buffer + let lights = LightBuffers.create 1 4 4 + let defaults = LightBuffers.create 1 4 4 + plan, LightScoping.replay buffer plan lights defaults + +let private ambient(intensity: float32) = + AmbientLight3D.create Mibo.Color.White + |> AmbientLight3D.withIntensity intensity + +let private dir castsShadows (direction: Vector3) = + DirectionalLight3D.create direction + |> DirectionalLight3D.withCastsShadows castsShadows + +let private point(at: Vector3) = PointLight3D.create(at, 10.0f) + +let private spot(at: Vector3) = + SpotLight3D.create(at, Vector3.UnitY, 20.0f) + +[] +let blockPlanTests = + testList "BlockPlan (raylib)" [ + test "empty buffer has no camera blocks" { + let plan = planOf [] + Expect.equal plan.BlockCount 0 "BlockCount" + Expect.isEmpty plan.Blocks "Blocks" + } + + test "single camera block counts once" { + let plan = planOf [ Command3D.BeginCamera cam; Command3D.EndCamera ] + Expect.equal plan.BlockCount 1 "BlockCount" + Expect.equal plan.Blocks.Length 1 "Blocks" + } + + test "two camera blocks count twice" { + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.equal plan.BlockCount 2 "BlockCount" + } + + test "nested BeginCamera closes the previous block" { + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.equal plan.BlockCount 2 "BlockCount" + } + + test "a block with light commands resets to defaults plus its own" { + let p1 = point Vector3.Zero + let dB = dir false Vector3.UnitX + + let plan = + planOf [ + Command3D.SetAmbientLight(ambient 0.5f) + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.1f) + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.2f) + Command3D.AddDirectionalLight dB + Command3D.EndCamera + ] + + let block1 = plan.Blocks[0].Lights + Expect.equal block1.Ambient (ValueSome(ambient 0.1f)) "block1 ambient" + Expect.sequenceEqual block1.PointLights [ p1 ] "block1 points" + Expect.isEmpty block1.DirLights "block1 dirs" + + let block2 = plan.Blocks[1].Lights + Expect.equal block2.Ambient (ValueSome(ambient 0.2f)) "block2 ambient" + Expect.sequenceEqual block2.DirLights [ dB ] "block2 dirs" + + Expect.isEmpty + block2.PointLights + "block2 does not inherit block1's own lights" + } + + test + "a block without light commands inherits the previous block's final set" { + let p1 = point Vector3.Zero + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.3f) + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + for i = 0 to 2 do + let lights = plan.Blocks[i].Lights + + Expect.equal + lights.Ambient + (ValueSome(ambient 0.3f)) + $"block{i} ambient" + + Expect.sequenceEqual lights.PointLights [ p1 ] $"block{i} points" + } + + test "lights before the first BeginCamera appear in every block" { + let d0 = dir false Vector3.UnitY + let p1 = point Vector3.One + + let plan = + planOf [ + Command3D.SetAmbientLight(ambient 0.4f) + Command3D.AddDirectionalLight d0 + Command3D.BeginCamera cam + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.equal + plan.FrameDefaults.Ambient + (ValueSome(ambient 0.4f)) + "defaults ambient" + + Expect.sequenceEqual plan.FrameDefaults.DirLights [ d0 ] "defaults dirs" + + let block1 = plan.Blocks[0].Lights + Expect.equal block1.Ambient (ValueSome(ambient 0.4f)) "block1 ambient" + Expect.sequenceEqual block1.DirLights [ d0 ] "block1 dirs" + Expect.sequenceEqual block1.PointLights [ p1 ] "block1 points" + + let block2 = plan.Blocks[1].Lights + Expect.equal block2.Ambient (ValueSome(ambient 0.4f)) "block2 ambient" + Expect.sequenceEqual block2.DirLights [ d0 ] "block2 dirs" + Expect.sequenceEqual block2.PointLights [ p1 ] "block2 points" + } + + test + "between-block commands update defaults; after-last-block commands affect no block" { + let dBetween = dir false Vector3.UnitX + let p2 = point Vector3.One + let sAfter = spot Vector3.Zero + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.AddPointLight p2 + Command3D.EndCamera + Command3D.AddSpotLight sAfter + ] + + Expect.isEmpty + plan.Blocks[0].Lights.DirLights + "block1 predates the between-block dir" + + let block2 = plan.Blocks[1].Lights + + Expect.sequenceEqual + block2.DirLights + [ dBetween ] + "block2 sees the updated defaults" + + Expect.sequenceEqual block2.PointLights [ p2 ] "block2 points" + Expect.isEmpty block2.SpotLights "after-last-block spot affects no block" + + Expect.sequenceEqual + plan.FrameDefaults.SpotLights + [ sAfter ] + "after-last-block spot lands in the defaults" + } + + test "an empty block inherits between-block commands via the running set" { + let p1 = point Vector3.Zero + let dBetween = dir false Vector3.UnitX + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + let block2 = plan.Blocks[1].Lights + + Expect.sequenceEqual + block2.PointLights + [ p1 ] + "block2 keeps block1's lights" + + Expect.sequenceEqual + block2.DirLights + [ dBetween ] + "block2 sees the between-block dir" + } + + test + "a resetting block after between-block commands sees them via the defaults" { + let p1 = point Vector3.Zero + let dBetween = dir false Vector3.UnitX + let s2 = spot Vector3.One + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.AddPointLight p1 + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.AddSpotLight s2 + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + let block2 = plan.Blocks[1].Lights + + Expect.sequenceEqual + block2.DirLights + [ dBetween ] + "block2 sees the between-block dir" + + Expect.sequenceEqual block2.SpotLights [ s2 ] "block2 spots" + + Expect.isEmpty + block2.PointLights + "a resetting block does not inherit block1's lights" + + let block3 = plan.Blocks[2].Lights + + Expect.sequenceEqual + block3.DirLights + [ dBetween ] + "block3 inherits block2's final set" + + Expect.sequenceEqual block3.SpotLights [ s2 ] "block3 spots" + } + + test "block slices cover the block's commands, half-open" { + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.EnableShadows + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.DisableShadows + ] + + Expect.equal plan.Blocks[0].StartIndex 1 "block1 start" + Expect.equal plan.Blocks[0].EndIndex 2 "block1 end" + Expect.equal plan.Blocks[1].StartIndex 4 "block2 start" + + Expect.equal + plan.Blocks[1].EndIndex + 5 + "an unclosed trailing block ends at the buffer end" + } + + test "each block sees exactly its own directional light" { + let dNoCast = dir false Vector3.UnitX + let dCast = dir true Vector3.UnitY + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.1f) + Command3D.AddDirectionalLight dNoCast + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.SetAmbientLight(ambient 0.6f) + Command3D.AddDirectionalLight dCast + Command3D.EndCamera + ] + + let dirs1 = plan.Blocks[0].Lights.DirLights + Expect.equal dirs1.Length 1 "block1 dir count" + Expect.isFalse dirs1[0].CastsShadows "block1 dir does not cast" + + let dirs2 = plan.Blocks[1].Lights.DirLights + Expect.equal dirs2.Length 1 "block2 dir count" + Expect.isTrue dirs2[0].CastsShadows "block2 dir casts" + } + + test + "DisableShadows before a block sets that block's initial cast state off" { + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.DisableShadows + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.isTrue plan.Blocks[0].InitialCastEnabled "block1 initial" + Expect.isFalse plan.Blocks[1].InitialCastEnabled "block2 initial" + } + + test "EnableShadows mid-block carries into the next block's initial state" { + let plan = + planOf [ + Command3D.DisableShadows + Command3D.BeginCamera cam + Command3D.EnableShadows + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.isFalse plan.Blocks[0].InitialCastEnabled "block1 initial" + Expect.isTrue plan.Blocks[1].InitialCastEnabled "block2 initial" + } + + test "SetShadowOrigin does not leak across blocks" { + let origin = Vector3(1.0f, 2.0f, 3.0f) + + let plan = + planOf [ + Command3D.BeginCamera cam + Command3D.SetShadowOrigin origin + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.equal + plan.Blocks[0].ShadowOrigin + (ValueSome origin) + "block1 origin" + + Expect.equal plan.Blocks[1].ShadowOrigin ValueNone "block2 origin" + } + + test "LightBuffers.copyInto copies contents without stale entries" { + // LightBuffers.create — LightBuffers.defaults is a shared module-level instance. + let source = LightBuffers.create 3 8 4 + source.Ambient <- ValueSome(ambient 0.7f) + source.DirLights.Add(dir true Vector3.UnitY) + source.PointLights.Add(point Vector3.Zero) + + let target = LightBuffers.create 3 8 4 + target.DirLights.Add(dir false Vector3.UnitX) + target.DirLights.Add(dir false Vector3.One) + target.SpotLights.Add(spot Vector3.One) + + LightBuffers.copyInto source target + + Expect.equal target.Ambient (ValueSome(ambient 0.7f)) "ambient" + Expect.sequenceEqual target.DirLights (Seq.toList source.DirLights) "dirs" + + Expect.sequenceEqual + target.PointLights + (Seq.toList source.PointLights) + "points" + + Expect.isEmpty target.SpotLights "stale spots are gone" + } + + // ── Live-replay regression tests (PR #89 review) ── + // The forward pass must shade each block with exactly plan.Blocks[k].Lights. + // Seeding the live buffers from plan.FrameDefaults diverged (double-applied + // between-block lights, future lights in early blocks, after-last-block leaks); + // the replay pins the seed-free protocol against the plan. + + test "live replay: between-block light is applied once, not twice" { + let dBetween = dir false Vector3.UnitX + let p2 = point Vector3.Zero + + let plan, sets = + replayOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.AddPointLight p2 + Command3D.EndCamera + ] + + Expect.sequenceEqual + sets[1].DirLights + [ dBetween ] + "block2 dirs applied exactly once" + + Expect.sequenceEqual + sets[1].DirLights + plan.Blocks[1].Lights.DirLights + "replay matches the plan" + } + + test "live replay: an early inheriting block does not see future lights" { + let dBetween = dir false Vector3.UnitX + + let plan, sets = + replayOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.EndCamera + ] + + Expect.isEmpty sets[0].DirLights "block1 predates the between-block dir" + + Expect.sequenceEqual + sets[1].DirLights + [ dBetween ] + "block2 inherits the running set" + + for i = 0 to 1 do + Expect.sequenceEqual + sets[i].DirLights + plan.Blocks[i].Lights.DirLights + $"block{i} matches the plan" + } + + test "live replay: after-last-block lights affect no block" { + let p2 = point Vector3.Zero + let sAfter = spot Vector3.Zero + + let plan, sets = + replayOf [ + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.BeginCamera cam + Command3D.AddPointLight p2 + Command3D.EndCamera + Command3D.AddSpotLight sAfter + ] + + Expect.equal sets.Length plan.BlockCount "one set per block" + Expect.isEmpty sets[1].SpotLights "after-last-block spot affects no block" + + for i = 0 to sets.Length - 1 do + Expect.sequenceEqual + sets[i].PointLights + plan.Blocks[i].Lights.PointLights + $"block{i} points match the plan" + + Expect.sequenceEqual + sets[i].SpotLights + plan.Blocks[i].Lights.SpotLights + $"block{i} spots match the plan" + } + + test "live replay: a resetting block starts from the defaults at its start" { + let p0 = point Vector3.One + let dBetween = dir false Vector3.UnitY + let p2 = point Vector3.Zero + + let plan, sets = + replayOf [ + Command3D.AddPointLight p0 + Command3D.BeginCamera cam + Command3D.EndCamera + Command3D.AddDirectionalLight dBetween + Command3D.BeginCamera cam + Command3D.AddPointLight p2 + Command3D.EndCamera + ] + + Expect.sequenceEqual + sets[1].PointLights + [ p0; p2 ] + "defaults points, then the block's own" + + Expect.sequenceEqual + sets[1].DirLights + [ dBetween ] + "between-block dir joins the defaults" + + for i = 0 to sets.Length - 1 do + Expect.equal + sets[i].Ambient + plan.Blocks[i].Lights.Ambient + $"block{i} ambient matches the plan" + + Expect.sequenceEqual + sets[i].DirLights + plan.Blocks[i].Lights.DirLights + $"block{i} dirs match the plan" + + Expect.sequenceEqual + sets[i].PointLights + plan.Blocks[i].Lights.PointLights + $"block{i} points match the plan" + + Expect.sequenceEqual + sets[i].SpotLights + plan.Blocks[i].Lights.SpotLights + $"block{i} spots match the plan" + } + ] diff --git a/src/Mibo.Raylib.Tests/Graphics3DTests.fs b/src/Mibo.Raylib.Tests/Graphics3DTests.fs index bb119e1..a62fdb5 100644 --- a/src/Mibo.Raylib.Tests/Graphics3DTests.fs +++ b/src/Mibo.Raylib.Tests/Graphics3DTests.fs @@ -728,6 +728,25 @@ let renderBuffer3DTests = Expect.equal buf.PostProcessCount 0 "Clear should reset PostProcessCount" } + test "CameraBlockCount tracks camera-block commands and resets on Clear" { + use buf = new RenderBuffer3D() + + Expect.equal buf.CameraBlockCount 0 "Fresh buffer has no camera blocks" + + buf.Add(Command3D.beginCamera(Unchecked.defaultof)) + buf.Add Command3D.EndCamera + buf.Add(Command3D.beginCamera(Unchecked.defaultof)) + buf.Add(Command3D.drawLine3D v3a v3b Color.White) + + Expect.equal + buf.CameraBlockCount + 2 + "Should count only Begin camera commands" + + buf.Clear() + Expect.equal buf.CameraBlockCount 0 "Clear should reset CameraBlockCount" + } + test "Sort with custom comparer reorders commands" { use buf = new RenderBuffer3D() buf.Add(Command3D.drawLine3D v3a v3b (Color(50uy, 0uy, 255uy, 255uy))) // R=50 @@ -1752,6 +1771,7 @@ let preScanTests = preScan( buffer, lights, + true, &fwd, &inst, &sk, @@ -1782,6 +1802,7 @@ let preScanTests = preScan( buffer, lights, + true, &fwd, &inst, &sk, @@ -1808,6 +1829,7 @@ let preScanTests = preScan( buffer, lights, + true, &fwd, &inst, &sk, @@ -1841,6 +1863,7 @@ let preScanTests = preScan( buffer, lights, + true, &fwd, &inst, &sk, @@ -1856,6 +1879,33 @@ let preScanTests = Expect.equal lights.SpotLights.Count 1 "Should have 1 spot light" } + test "skips light gathering when gatherLights is false" { + use buffer = new RenderBuffer3D() + buffer.Add(Command3D.addDirectionalLight(DirectionalLight3D.create v3a)) + buffer.Add(Command3D.addPointLight(PointLight3D.create(v3b, 10.0f))) + let lights = createLightBuffers(8, 4) + let mutable fwd = Unchecked.defaultof + let mutable inst = Unchecked.defaultof + let mutable sk = Unchecked.defaultof + + let _fs = + preScan( + buffer, + lights, + false, + &fwd, + &inst, + &sk, + Unchecked.defaultof<_>, + Unchecked.defaultof<_>, + Unchecked.defaultof<_>, + ValueNone + ) + + Expect.equal lights.DirLights.Count 0 "No dir lights gathered" + Expect.equal lights.PointLights.Count 0 "No point lights gathered" + } + test "empty buffer returns empty frame state" { use buffer = new RenderBuffer3D() let lights = createLightBuffers(8, 4) @@ -1867,6 +1917,7 @@ let preScanTests = preScan( buffer, lights, + true, &fwd, &inst, &sk, diff --git a/src/Mibo.Raylib.Tests/Mibo.Raylib.Tests.fsproj b/src/Mibo.Raylib.Tests/Mibo.Raylib.Tests.fsproj index b8e2188..7ed3c6b 100644 --- a/src/Mibo.Raylib.Tests/Mibo.Raylib.Tests.fsproj +++ b/src/Mibo.Raylib.Tests/Mibo.Raylib.Tests.fsproj @@ -17,6 +17,8 @@ + + diff --git a/src/Mibo.Raylib.Tests/ShadowPassTests.fs b/src/Mibo.Raylib.Tests/ShadowPassTests.fs new file mode 100644 index 0000000..ccd61f7 --- /dev/null +++ b/src/Mibo.Raylib.Tests/ShadowPassTests.fs @@ -0,0 +1,77 @@ +module Mibo.Raylib.Tests.ShadowPassTests + +open System.Numerics +open Expecto +open Mibo.Elmish.Graphics3D +open Mibo.Elmish.Graphics3D.Pipelines + +let private dir castsShadows = + DirectionalLight3D.create Vector3.UnitY + |> DirectionalLight3D.withCastsShadows castsShadows + +let private castingPoint = + PointLight3D.create(Vector3.Zero, 10.0f) |> PointLight3D.withCastsShadows true + +[] +let shadowPassTests = + testList "collectShadowCasters (raylib)" [ + test "casters re-pack from slot 0 per registration" { + let atlas = + ShadowAtlas(ShadowAtlasConfig.defaults, ShadowBiasConfig.defaults) + + let lights = LightBuffers.create 3 8 4 + lights.DirLights.Add(dir true) + lights.PointLights.Add castingPoint + lights.PointLights.Add castingPoint + + let pointSlots = Array.create lights.PointLights.Count -1 + let spotSlots = Array.create lights.SpotLights.Count -1 + + let hasCasters = + collectShadowCasters(lights, atlas, pointSlots, spotSlots) + + Expect.isTrue hasCasters "dir + 2 point casters" + Expect.sequenceEqual pointSlots [ 1; 2 ] "dir takes slot 0, points follow" + + // A later registration with fewer casters re-packs from slot 0 (per-block shadow + // passes clear the atlas and re-register each block's light set from scratch). + atlas.Clear() + + let later = LightBuffers.create 3 8 4 + later.PointLights.Add castingPoint + + let laterPointSlots = Array.create later.PointLights.Count -1 + let laterSpotSlots = Array.create later.SpotLights.Count -1 + + let laterHasCasters = + collectShadowCasters(later, atlas, laterPointSlots, laterSpotSlots) + + Expect.isTrue laterHasCasters "one point caster" + Expect.sequenceEqual laterPointSlots [ 0 ] "re-packed from slot 0" + } + + test "only the first directional light can cast" { + let atlas = + ShadowAtlas(ShadowAtlasConfig.defaults, ShadowBiasConfig.defaults) + + let nonCastingFirst = LightBuffers.create 3 8 4 + nonCastingFirst.DirLights.Add(dir false) + nonCastingFirst.DirLights.Add(dir true) + + let hasCasters = collectShadowCasters(nonCastingFirst, atlas, [||], [||]) + + Expect.isFalse + hasCasters + "a non-casting DirLights[0] means no directional caster" + + atlas.Clear() + + let castingFirst = LightBuffers.create 3 8 4 + castingFirst.DirLights.Add(dir true) + castingFirst.DirLights.Add(dir false) + + let hasCasters2 = collectShadowCasters(castingFirst, atlas, [||], [||]) + + Expect.isTrue hasCasters2 "DirLights[0] casts" + } + ] diff --git a/src/Mibo.Raylib/Graphics3D/Pipelines/BlockPlan.fs b/src/Mibo.Raylib/Graphics3D/Pipelines/BlockPlan.fs new file mode 100644 index 0000000..1b346b7 --- /dev/null +++ b/src/Mibo.Raylib/Graphics3D/Pipelines/BlockPlan.fs @@ -0,0 +1,272 @@ +namespace Mibo.Elmish.Graphics3D.Pipelines + +open System.Numerics +open Mibo.Elmish.Graphics3D + +/// +/// An immutable snapshot of a light set: the ambient slot plus the directional, point, and +/// spot light arrays. Light-state semantics are per light type — each kind is an +/// independent array, so a set may hold any number of directional lights. +/// +[] +type BlockLightSet = { + /// The ambient light (single slot; a later ambient command overwrites the earlier one). + Ambient: AmbientLight3D voption + + /// The directional lights, in command order. + DirLights: DirectionalLight3D[] + + /// The point lights, in command order. + PointLights: PointLight3D[] + + /// The spot lights, in command order. + SpotLights: SpotLight3D[] +} + +/// The plan for one camera block (BeginCamera/BeginCameraConfig … EndCamera). +[] +type CameraBlockPlan = { + /// Whether the block contains at least one light command. + HasLightCommands: bool + + /// + /// The block's final light set. A block with light commands starts from the frame defaults + /// and applies its own commands in-order (ambient overwrites; directional/point/spot append). + /// A block without light commands inherits the running light set at its start: the previous + /// block's final set plus any light commands issued between the two blocks. + /// + Lights: BlockLightSet + + /// The block's shadow origin override, if SetShadowOrigin was issued inside it (last one wins). + ShadowOrigin: Vector3 voption + + /// The running EnableShadows/DisableShadows toggle state at the block's start. + InitialCastEnabled: bool + + /// The buffer index of the block's first command (one past its Begin command). + StartIndex: int + + /// The buffer index one past the block's last command (its EndCamera, the next + /// Begin, or the end of the buffer) — the block's commands are the half-open range + /// [StartIndex, EndIndex). + EndIndex: int +} + +/// +/// The result of walking a once: how many +/// camera blocks the frame contains, the per-block light/shadow state, and the frame-default +/// light set that blocks with their own light commands reset to. +/// +/// +/// Light commands outside any camera block accumulate into the frame defaults — including +/// commands between blocks (after an EndCamera, before the next BeginCamera) and +/// after the last EndCamera (those affect no block). Between-block commands also join the +/// running light set that blocks without their own light commands inherit. +/// SetShadowOrigin is scoped to the block it appears in and never leaks across blocks. +/// +[] +type BlockPlan = { + /// The number of camera blocks in the buffer. + BlockCount: int + + /// The per-block plans, indexed by block (in buffer order). + Blocks: CameraBlockPlan[] + + /// The frame-default light set: every light command issued outside a camera block. + FrameDefaults: BlockLightSet +} + +// ───────────────────────────────────────────────────────────────────────────── +// Walk accumulators — the mutable state BlockPlan.build folds over. Reference +// records (one per open block per frame) so helpers mutate them in place; the +// immutable plan records above are the only data that leaves the module. +// ───────────────────────────────────────────────────────────────────────────── + +type private LightAccum = { + mutable Ambient: AmbientLight3D voption + DirLights: ResizeArray + PointLights: ResizeArray + SpotLights: ResizeArray +} + +type private BlockAccum = { + mutable HasLightCommands: bool + Lights: LightAccum + mutable ShadowOrigin: Vector3 voption + InitialCastEnabled: bool + StartIndex: int +} + +type private WalkState = { + Defaults: LightAccum + Running: LightAccum + Blocks: ResizeArray + mutable Current: BlockAccum voption + mutable CastEnabled: bool +} + +module private BlockPlanWalk = + + let inline lightAccum() : LightAccum = { + Ambient = ValueNone + DirLights = ResizeArray() + PointLights = ResizeArray() + SpotLights = ResizeArray() + } + + let inline applyLight (lights: LightAccum) (cmd: Command3D) = + match cmd with + | Command3D.SetAmbientLight a -> lights.Ambient <- ValueSome a + | Command3D.AddDirectionalLight d -> lights.DirLights.Add d + | Command3D.AddPointLight p -> lights.PointLights.Add p + | Command3D.AddSpotLight s -> lights.SpotLights.Add s + | _ -> () + + let inline snapshot(lights: LightAccum) : BlockLightSet = { + Ambient = lights.Ambient + DirLights = lights.DirLights.ToArray() + PointLights = lights.PointLights.ToArray() + SpotLights = lights.SpotLights.ToArray() + } + + let inline replaceContents (set: BlockLightSet) (accum: LightAccum) = + accum.Ambient <- set.Ambient + accum.DirLights.Clear() + accum.DirLights.AddRange(set.DirLights) + accum.PointLights.Clear() + accum.PointLights.AddRange(set.PointLights) + accum.SpotLights.Clear() + accum.SpotLights.AddRange(set.SpotLights) + + let inline merged (defaults: LightAccum) (own: LightAccum) : BlockLightSet = { + Ambient = + match own.Ambient with + | ValueSome _ -> own.Ambient + | ValueNone -> defaults.Ambient + DirLights = + Array.append (defaults.DirLights.ToArray()) (own.DirLights.ToArray()) + PointLights = + Array.append (defaults.PointLights.ToArray()) (own.PointLights.ToArray()) + SpotLights = + Array.append (defaults.SpotLights.ToArray()) (own.SpotLights.ToArray()) + } + + let inline finalizeBlock + (defaults: LightAccum) + (running: LightAccum) + (endIndex: int) + (block: BlockAccum) + : CameraBlockPlan = + let lights = + if block.HasLightCommands then + let final = merged defaults block.Lights + replaceContents final running + final + else + snapshot running + + { + HasLightCommands = block.HasLightCommands + Lights = lights + ShadowOrigin = block.ShadowOrigin + InitialCastEnabled = block.InitialCastEnabled + StartIndex = block.StartIndex + EndIndex = endIndex + } + + let closeCurrent (state: WalkState) (endIndex: int) = + match state.Current with + | ValueSome block -> + let plan = finalizeBlock state.Defaults state.Running endIndex block + state.Blocks.Add plan + state.Current <- ValueNone + | ValueNone -> () + + /// A BeginCamera while a block is open closes that block first (nested camera + /// commands are tolerated, matching the forward pass). + let beginBlock (state: WalkState) (startIndex: int) = + closeCurrent state (startIndex - 1) + + state.Current <- + ValueSome( + { + HasLightCommands = false + Lights = lightAccum() + ShadowOrigin = ValueNone + InitialCastEnabled = state.CastEnabled + StartIndex = startIndex + } + ) + + let lightCommand (state: WalkState) (cmd: Command3D) = + match state.Current with + | ValueSome block -> + block.HasLightCommands <- true + applyLight block.Lights cmd + | ValueNone -> + applyLight state.Defaults cmd + applyLight state.Running cmd + + let shadowOrigin (state: WalkState) (origin: Vector3) = + match state.Current with + | ValueSome block -> block.ShadowOrigin <- ValueSome origin + | ValueNone -> () + + let finish (state: WalkState) (endIndex: int) : BlockPlan = + closeCurrent state endIndex + + { + BlockCount = state.Blocks.Count + Blocks = state.Blocks.ToArray() + FrameDefaults = snapshot state.Defaults + } + +/// Builds a from a render buffer. +module BlockPlan = + + /// + /// The empty plan: no camera blocks, no frame defaults. Pipelines use it for single-camera + /// frames, which skip the buffer walk () + /// and its allocations entirely — every plan consumer is gated on the multi-block count. + /// + let empty: BlockPlan = { + BlockCount = 0 + Blocks = [||] + FrameDefaults = { + Ambient = ValueNone + DirLights = [||] + PointLights = [||] + SpotLights = [||] + } + } + + /// + /// Walks the buffer once and produces the per-camera-block light/shadow plan for the frame: + /// the block count, each block's final light set / shadow origin / initial shadow-caster + /// toggle / buffer slice, and the frame-default light set. + /// + let build(buffer: RenderBuffer3D) : BlockPlan = + let state: WalkState = { + Defaults = BlockPlanWalk.lightAccum() + Running = BlockPlanWalk.lightAccum() + Blocks = ResizeArray() + Current = ValueNone + CastEnabled = true + } + + for i = 0 to buffer.Count - 1 do + match buffer[i] with + | Command3D.BeginCamera _ + | Command3D.BeginCameraConfig _ -> BlockPlanWalk.beginBlock state (i + 1) + | Command3D.EndCamera -> BlockPlanWalk.closeCurrent state i + | Command3D.SetAmbientLight _ + | Command3D.AddDirectionalLight _ + | Command3D.AddPointLight _ + | Command3D.AddSpotLight _ as cmd -> BlockPlanWalk.lightCommand state cmd + | Command3D.SetShadowOrigin origin -> + BlockPlanWalk.shadowOrigin state origin + | Command3D.EnableShadows -> state.CastEnabled <- true + | Command3D.DisableShadows -> state.CastEnabled <- false + | _ -> () + + BlockPlanWalk.finish state buffer.Count diff --git a/src/Mibo.Raylib/Graphics3D/Pipelines/ForwardPbrPipeline.fs b/src/Mibo.Raylib/Graphics3D/Pipelines/ForwardPbrPipeline.fs index 93bb3ac..9647b1f 100644 --- a/src/Mibo.Raylib/Graphics3D/Pipelines/ForwardPbrPipeline.fs +++ b/src/Mibo.Raylib/Graphics3D/Pipelines/ForwardPbrPipeline.fs @@ -320,16 +320,22 @@ module internal ShadowPassHelpers = InstanceCount: int } - let collectMeshDraws(buffer: RenderBuffer3D) = + let collectMeshDraws + ( + buffer: RenderBuffer3D, + startIdx: int, + endIdx: int, + initialShadowsEnabled: bool + ) = let pool = ArrayPool.Shared let instPool = ArrayPool.Shared let mutable meshCount = 0 let mutable instancedCount = 0 - let mutable shadowsEnabled = true - let mutable i = 0 + let mutable shadowsEnabled = initialShadowsEnabled + let mutable i = startIdx - while i < buffer.Count do + while i < endIdx do match buffer[i] with | Command3D.DisableShadows -> shadowsEnabled <- false | Command3D.EnableShadows -> shadowsEnabled <- true @@ -351,10 +357,10 @@ module internal ShadowPassHelpers = let mutable count = 0 let mutable icount = 0 let mutable skinnedStart = count - shadowsEnabled <- true - i <- 0 + shadowsEnabled <- initialShadowsEnabled + i <- startIdx - while i < buffer.Count do + while i < endIdx do match buffer[i] with | Command3D.DisableShadows -> shadowsEnabled <- false | Command3D.EnableShadows -> shadowsEnabled <- true @@ -436,21 +442,26 @@ module internal ShadowPassHelpers = struct (arr, count, skinnedStart, instArr, icount) /// - /// Register shadow casters for every shadow-casting light. Returns: - /// - hasCasters: true if any caster was registered. - /// - pointShadowSlots: array indexed by lights.PointLights buffer position; + /// Register shadow casters for every shadow-casting light into the caller-provided slot + /// arrays (grow-only scratch owned by the pipeline, pre-filled with -1 by the caller): + /// - pointShadowSlots: indexed by lights.PointLights buffer position; /// value is the caster's flat shader-array index, or -1 if the light doesn't cast / atlas full. /// - spotShadowSlots: same shape for spot lights. + /// Returns true when at least one caster was registered. /// /// The flat index equals the order in which casters are registered (dir first, then point, /// then spot), which matches ShadowAtlas.PrepareUniforms's flattening order — so the /// value uploaded to pointLightShadowIdx[i] indexes shadowViewProjs[idx] correctly. /// - let collectShadowCasters(lights: LightBuffers, atlas: ShadowAtlas) = + let collectShadowCasters + ( + lights: LightBuffers, + atlas: ShadowAtlas, + pointShadowSlots: int[], + spotShadowSlots: int[] + ) = let mutable hasCasters = false let mutable casterSlot = 0 - let pointShadowSlots = Array.create lights.PointLights.Count -1 - let spotShadowSlots = Array.create lights.SpotLights.Count -1 let tryAdd casterType pos dir target bias = match atlas.AddCaster(casterType, pos, dir, target, true, bias) with @@ -461,16 +472,11 @@ module internal ShadowPassHelpers = ValueSome slot | ValueNone -> ValueNone - // Only the first shadow-casting directional light is sampled by the forward shader - // (computeDirShadow uses slot 0); registering more would waste atlas slots + render cost. - let mutable dirShadowIdx = -1 - - for i = 0 to lights.DirLights.Count - 1 do - if dirShadowIdx < 0 && lights.DirLights[i].CastsShadows then - dirShadowIdx <- i - - if dirShadowIdx >= 0 then - let dir = lights.DirLights[dirShadowIdx] + // Only the first directional light is shaded (and uploaded) by the forward shader, + // so only it can cast — a non-casting DirLights[0] means no directional caster, + // even if a later directional light has CastsShadows set. + if lights.DirLights.Count > 0 && lights.DirLights[0].CastsShadows then + let dir = lights.DirLights[0] tryAdd ShadowCasterType.Directional @@ -515,7 +521,7 @@ module internal ShadowPassHelpers = | ValueSome slot -> spotShadowSlots[i] <- slot | ValueNone -> () - struct (hasCasters, pointShadowSlots, spotShadowSlots) + hasCasters /// Build an orthographic camera for directional-light shadow rendering. let createDirectionalShadowCamera @@ -589,6 +595,106 @@ module internal ShadowPassHelpers = Projection = CameraProjection.Orthographic ) +// ------------------------------------------------------------------ +// Per-camera-block light scoping +// ------------------------------------------------------------------ + +/// +/// Light-state transitions for multi-camera-block frames: applying one light command in-order, +/// loading a materialized light set into a live buffer set, and resetting the live buffers at a +/// camera block's start. +/// +module internal LightScoping = + + /// Applies one light command in-order: ambient overwrites; directional/point/spot append. + let inline apply (lights: LightBuffers) (cmd: Command3D) = + match cmd with + | Command3D.SetAmbientLight a -> lights.Ambient <- ValueSome a + | Command3D.AddDirectionalLight d -> lights.DirLights.Add d + | Command3D.AddPointLight p -> lights.PointLights.Add p + | Command3D.AddSpotLight s -> lights.SpotLights.Add s + | _ -> () + + /// Loads a materialized light set into a live buffer set, replacing its contents. + let inline loadSet (set: BlockLightSet) (lights: LightBuffers) = + lights.Ambient <- set.Ambient + lights.DirLights.Clear() + lights.DirLights.AddRange(set.DirLights) + lights.PointLights.Clear() + lights.PointLights.AddRange(set.PointLights) + lights.SpotLights.Clear() + lights.SpotLights.AddRange(set.SpotLights) + + /// + /// Advances to the next camera block and, when that block carries its own light commands, + /// resets the live buffers to the frame defaults — the block's commands are applied in-order + /// as the forward loop reaches them. A block without light commands leaves the live buffers + /// untouched (inheritance). Returns whether the buffers were reset. + /// + let inline resetForBlock + (plan: BlockPlan) + (defaults: LightBuffers) + (lights: LightBuffers) + (blockIndex: byref) + : bool = + blockIndex <- blockIndex + 1 + + if plan.Blocks[blockIndex].HasLightCommands then + LightBuffers.copyInto defaults lights + true + else + false + + /// + /// Replays the buffer's camera and light commands the way the multi-camera-block forward + /// pass does — both buffers start empty, between-block commands accumulate into the + /// defaults, and each block resets (own commands) or inherits at its start — returning + /// every block's live light set at its close. Test hook pinning that live shading matches + /// the ; allocates a snapshot per + /// block, so it is not for the hot path. + /// + let replay + (buffer: RenderBuffer3D) + (plan: BlockPlan) + (lights: LightBuffers) + (defaults: LightBuffers) + : BlockLightSet[] = + let sets = ResizeArray(plan.BlockCount) + let mutable blockIndex = -1 + let mutable inBlock = false + + let closeBlock() = + if inBlock then + sets.Add { + Ambient = lights.Ambient + DirLights = lights.DirLights.ToArray() + PointLights = lights.PointLights.ToArray() + SpotLights = lights.SpotLights.ToArray() + } + + inBlock <- false + + for i = 0 to buffer.Count - 1 do + match buffer[i] with + | Command3D.BeginCamera _ + | Command3D.BeginCameraConfig _ -> + closeBlock() + resetForBlock plan defaults lights &blockIndex |> ignore + inBlock <- true + | Command3D.EndCamera -> closeBlock() + | Command3D.SetAmbientLight _ + | Command3D.AddDirectionalLight _ + | Command3D.AddPointLight _ + | Command3D.AddSpotLight _ as cmd -> + apply lights cmd + + if not inBlock then + apply defaults cmd + | _ -> () + + closeBlock() + sets.ToArray() + // ------------------------------------------------------------------ // Pure / near-pure functions // ------------------------------------------------------------------ @@ -958,6 +1064,34 @@ module internal PipelineFunctions = setShaderVec3 shader cameraLoc cameraPos setShaderInt shader shadowLocs.Pass 0 + /// + /// Builds the shadow result from the atlas's current state: ValueNone when no caster + /// registered or none fit the atlas; otherwise the packed uniforms + per-light slot mappings + /// a custom shader reads to opt into shadow sampling. + /// + let inline shadowResultOf + (atlas: ShadowAtlas) + (atlasCfg: ShadowAtlasConfig) + (hasCasters: bool) + (dirCasts: bool) + (pointSlots: int[]) + (spotSlots: int[]) + : ShadowResult voption = + if hasCasters && atlas.ActiveCasterCount > 0 then + ValueSome { + Atlas = atlas.Fbo.Depth + ViewProjs = atlas.ViewProjs + UVOffsets = atlas.UVOffsets + ActiveCasterCount = atlas.ActiveCasterCount + TexelSize = 1.0f / float32 atlasCfg.Resolution + Biases = atlas.Biases + DirLightCastsShadows = dirCasts + PointLightShadowIdx = pointSlots + SpotLightShadowIdx = spotSlots + } + else + ValueNone + /// Upload shadow atlas uniforms to all three shader variants. let uploadShadowUniforms ( @@ -1364,6 +1498,7 @@ module internal PipelineFunctions = ( buffer: RenderBuffer3D, lights: LightBuffers, + gatherLights: bool, forward: byref, instanced: byref, skinned: byref, @@ -1400,10 +1535,18 @@ module internal PipelineFunctions = frameState with ShadowOrigin = ValueSome origin } - | Command3D.SetAmbientLight l -> lights.Ambient <- ValueSome l - | Command3D.AddDirectionalLight l -> lights.DirLights.Add l - | Command3D.AddPointLight l -> lights.PointLights.Add l - | Command3D.AddSpotLight l -> lights.SpotLights.Add l + | Command3D.SetAmbientLight l -> + if gatherLights then + lights.Ambient <- ValueSome l + | Command3D.AddDirectionalLight l -> + if gatherLights then + lights.DirLights.Add l + | Command3D.AddPointLight l -> + if gatherLights then + lights.PointLights.Add l + | Command3D.AddSpotLight l -> + if gatherLights then + lights.SpotLights.Add l | Command3D.DrawMesh(_, _, mat) -> warmMaterial( &forward, @@ -1593,7 +1736,10 @@ module internal PipelineFunctions = Raylib.EndMode3D() - /// Render the shadow pass — collect casters, render regions to atlas. + /// Render the shadow pass — collect casters, render regions to atlas. Returns whether any + /// caster was registered. The per-light shadow-slot mappings live in caller-owned grow-only + /// arrays (the pipeline's fields), resized here when a pass sees more lights than any + /// previous pass and reset to -1 ("no shadow") on entry. let runShadowPass ( shadowAtlas: ShadowAtlas, @@ -1606,15 +1752,23 @@ module internal PipelineFunctions = instancedDraws: InstancedMeshDraw[], instancedDrawCount: int, frameState: inref, - gameCtx: GameContext - ) = + gameCtx: GameContext, + pointSlots: byref, + spotSlots: byref + ) : bool = shadowAtlas.Clear() + if pointSlots.Length < lights.PointLights.Count then + pointSlots <- Array.create lights.PointLights.Count -1 + + Array.Fill(pointSlots, -1) + + if spotSlots.Length < lights.SpotLights.Count then + spotSlots <- Array.create lights.SpotLights.Count -1 + + Array.Fill(spotSlots, -1) + let mutable hasCasters = false - // Per-light shadow-slot mappings; returned to the caller (the closure stores them on the - // pipeline fields so the forward-pass handlers can read them). - let mutable pointSlots = Array.create lights.PointLights.Count -1 - let mutable spotSlots = Array.create lights.SpotLights.Count -1 match frameState.Camera with | ValueNone -> @@ -1624,12 +1778,8 @@ module internal PipelineFunctions = // Instanced-only scenes cast shadows too (matches the MonoGame backend, which gates // on mesh + instanced counts). if meshDrawCount > 0 || instancedDrawCount > 0 then - let struct (hasC, ptSlots, spSlots) = - collectShadowCasters(lights, shadowAtlas) - - hasCasters <- hasC - pointSlots <- ptSlots - spotSlots <- spSlots + hasCasters <- + collectShadowCasters(lights, shadowAtlas, pointSlots, spotSlots) if shadowAtlas.Count > 0 then Raylib.BeginTextureMode(shadowAtlas.Fbo) @@ -1746,24 +1896,34 @@ module internal PipelineFunctions = Rlgl.Viewport(0, 0, gameCtx.WindowWidth, gameCtx.WindowHeight) Raylib.EndTextureMode() - struct (hasCasters, pointSlots, spotSlots) + hasCasters // ------------------------------------------------------------------ // ForwardFrame — per-frame scene state the Shade hook reads (byref, no alloc). // ------------------------------------------------------------------ /// Per-frame scene state passed to . +/// +/// is frame-global in +/// single-camera frames; in frames with more than one camera block it is scoped to the block +/// currently being drawn (reset-with-inheritance — see +/// ), and the shadow fields are +/// reseated from that block's shadow pass at the block's start. +/// [] type ForwardFrame = { - /// The frame's accumulated lights. + /// The active light set (see type remarks). Lights: LightBuffers - /// Per-light shadow atlas slots (-1 = no shadow), indexed by PointLights position. - PointShadowSlots: int[] - /// Per-light shadow atlas slots (-1 = no shadow), indexed by SpotLights position. - SpotShadowSlots: int[] - /// The frame's shadow pass output — ValueNone when no shadow-casting light. + /// Per-light shadow atlas slots (-1 = no shadow), indexed by PointLights position. + /// Reseated from the shadow pass output at each camera block's start. + mutable PointShadowSlots: int[] + /// Per-light shadow atlas slots (-1 = no shadow), indexed by SpotLights position. + /// Reseated from the shadow pass output at each camera block's start. + mutable SpotShadowSlots: int[] + /// The active shadow pass output — ValueNone when no shadow-casting light. + /// Reseated from the shadow pass output at each camera block's start. /// The user-effect scope uploads these uniforms by name so a custom shader can opt into shadows. - Shadows: ShadowResult voption + mutable Shadows: ShadowResult voption /// Total elapsed game time, in seconds — the time uniform for animated shaders. Time: float32 } @@ -1793,6 +1953,13 @@ type ForwardFrame = { /// /// /// +/// In frames with more than one camera block, lights and shadows are scoped per block: a block +/// that issues light commands resets to the frame defaults (light commands issued outside any +/// camera block) plus its own commands, applied in-order; a block without light commands +/// inherits the running set. Each block renders its own shadow map at its start. Single-camera +/// frames gather lights frame-globally and render one shadow map for the frame. +/// +/// /// Override Shade to plug a different shading strategy (toon, cel, custom). The scene /// gather, shadow pass, and forward-pass dispatch are inherited. /// @@ -1868,6 +2035,15 @@ type ForwardPipelineBase let lights: LightBuffers = createLightBuffers(maxPt, maxSp) + // Frame-default light set for multi-camera-block frames: repopulated from the block plan + // each frame; a block that issues its own light commands resets the live buffers from this. + let defaultLights: LightBuffers = createLightBuffers(maxPt, maxSp) + + // Scratch for a block's final light set (loaded from the block plan) when running that + // block's shadow pass — the live buffers trail the block's own in-order commands at block + // start, so the pass can't read them. + let blockLights: LightBuffers = createLightBuffers(maxPt, maxSp) + let applyPostProcess (ctx: GameContext) (sceneTarget: RenderTexture2D) @@ -1915,6 +2091,175 @@ type ForwardPipelineBase src <- target | ValueNone -> () + // ---------------------------------------------------------------- + // Shadow passes — the frame-global single-camera pass and the per-block + // multi-camera-block pass. + // ---------------------------------------------------------------- + + /// + /// Single-camera shadow pass: collects casters frame-globally, renders the atlas, uploads + /// shadow uniforms to all three shader variants, and returns the frame's shadow result. + /// + member private this.runFrameShadowPass + ( + gameCtx: GameContext, + buffer: RenderBuffer3D, + resources: inref, + frameState: inref + ) : ShadowResult voption = + let struct (meshDraws, meshDrawCount, skinnedStart, instancedDraws, + instancedDrawCount) = + collectMeshDraws(buffer, 0, buffer.Count, true) + + let mutable hasCasters = false + + try + hasCasters <- + runShadowPass( + shadowAtlas, + atlasCfg, + &resources, + lights, + meshDraws, + meshDrawCount, + skinnedStart, + instancedDraws, + instancedDrawCount, + &frameState, + gameCtx, + &pointShadowSlots, + &spotShadowSlots + ) + finally + ArrayPool.Shared.Return(meshDraws, false) + ArrayPool.Shared.Return(instancedDraws, false) + + match frameState.Camera with + | ValueSome cam -> + uploadShadowUniforms( + hasCasters, + &forward, + &instanced, + &skinned, + shadowAtlas, + cam.Position, + atlasCfg.MaxCasters + ) + | ValueNone -> () + + shadowResultOf + shadowAtlas + atlasCfg + hasCasters + (lights.DirLights.Count > 0 && lights.DirLights[0].CastsShadows) + pointShadowSlots + spotShadowSlots + + /// + /// Multi-camera-block block start: resets the live light buffers when the block carries its + /// own light commands (a block without any inherits them untouched), then renders this + /// block's shadow map — from the block's final light set, shadow origin, and buffer slice — + /// and reseats the frame bundle's shadow state from the pass. + /// + /// + /// raylib has no FBO stack: EndTextureMode rebinds the back buffer, so under + /// post-processing the pass can't run inside the scene target's texture mode — the caller's + /// texture mode is unwrapped and re-wrapped around it (boundaries flush the render batch). + /// + member private this.beginShadowedBlock + ( + gameCtx: GameContext, + buffer: RenderBuffer3D, + resources: inref, + sceneRT: RenderTexture2D voption, + plan: BlockPlan, + blockIndex: byref, + camera: Camera3D, + frame: byref + ) = + if LightScoping.resetForBlock plan defaultLights lights &blockIndex then + forward.LightsDirty <- true + instanced.LightsDirty <- true + skinned.LightsDirty <- true + + let block = plan.Blocks[blockIndex] + LightScoping.loadSet block.Lights blockLights + + match sceneRT with + | ValueSome _ -> Raylib.EndTextureMode() + | ValueNone -> () + + // Re-wrap the caller's texture mode even when the pass throws — raylib has no FBO + // stack, so an unwound frame would otherwise leave the pipeline outside texture mode. + try + let struct (meshDraws, meshDrawCount, skinnedStart, instancedDraws, + instancedDrawCount) = + collectMeshDraws( + buffer, + block.StartIndex, + block.EndIndex, + block.InitialCastEnabled + ) + + let mutable blockFrame = { + Camera = ValueSome camera + ShadowOrigin = block.ShadowOrigin + } + + try + let hasC = + runShadowPass( + shadowAtlas, + atlasCfg, + &resources, + blockLights, + meshDraws, + meshDrawCount, + skinnedStart, + instancedDraws, + instancedDrawCount, + &blockFrame, + gameCtx, + &pointShadowSlots, + &spotShadowSlots + ) + + uploadShadowUniforms( + hasC, + &forward, + &instanced, + &skinned, + shadowAtlas, + camera.Position, + atlasCfg.MaxCasters + ) + + if not hasC then + // Caster-less block: clear the flag so shaders don't sample the previous block's atlas. + setShaderInt forwardShader forward.Locs.DirLight.CastsShadows 0 + setShaderInt instancedShader instanced.Locs.DirLight.CastsShadows 0 + setShaderInt skinnedShader skinned.Locs.DirLight.CastsShadows 0 + + frame.PointShadowSlots <- pointShadowSlots + frame.SpotShadowSlots <- spotShadowSlots + + frame.Shadows <- + shadowResultOf + shadowAtlas + atlasCfg + hasC + (blockLights.DirLights.Count > 0 + && blockLights.DirLights[0].CastsShadows) + pointShadowSlots + spotShadowSlots + finally + ArrayPool.Shared.Return(meshDraws, false) + ArrayPool.Shared.Return(instancedDraws, false) + finally + match sceneRT with + | ValueSome rt -> Raylib.BeginTextureMode rt + | ValueNone -> () + // ---------------------------------------------------------------- // Per-draw shading hook — overridable. // @@ -2324,10 +2669,23 @@ type ForwardPipelineBase member this.Execute(gameCtx, gameTime, buffer, rtPool) = let frameTime = float32 gameTime.TotalTime.TotalSeconds - // Pre-scan: gather camera, lights, shadow origin, warm material caches, and — when present — - // post-process actions in a single pass over the buffer. + // Pre-scan: gather camera, shadow origin, warm material caches, and — when present — + // post-process actions in a single pass over the buffer. Lights are gathered here only + // for single-camera frames; multi-block frames scope them per camera block in the + // forward pass instead. clearLights lights + // The block plan walks the buffer once for the per-camera-block light/shadow scoping. + // Single-camera frames skip the walk (and its allocations) entirely — the counter is + // maintained by the buffer on Add. + let multiBlock = buffer.CameraBlockCount > 1 + + let plan = + if multiBlock then + BlockPlan.build buffer + else + BlockPlan.empty + // Allocated only when the view emits at least one post-process command, so frames with none // skip the allocation and the per-command scan entirely. let ppActions: ResizeArray unit> voption = @@ -2340,6 +2698,7 @@ type ForwardPipelineBase preScan( buffer, lights, + not multiBlock, &forward, &instanced, &skinned, @@ -2353,11 +2712,6 @@ type ForwardPipelineBase instanced.LightsDirty <- true skinned.LightsDirty <- true - // Shadow pass — render all casters into the atlas - let struct (meshDraws, meshDrawCount, skinnedStart, instancedDraws, - instancedDrawCount) = - collectMeshDraws buffer - let shadowResources = { Shader = depthShadowShader SkinnedShader = depthShadowSkinnedShader @@ -2370,62 +2724,19 @@ type ForwardPipelineBase BoneLoc = shadowBoneLoc } - let mutable hasShadowCasters = false - - try - let struct (hasC, ptSlots, spSlots) = - runShadowPass( - shadowAtlas, - atlasCfg, - &shadowResources, - lights, - meshDraws, - meshDrawCount, - skinnedStart, - instancedDraws, - instancedDrawCount, - &frameState, - gameCtx - ) - - hasShadowCasters <- hasC - pointShadowSlots <- ptSlots - spotShadowSlots <- spSlots - finally - ArrayPool.Shared.Return(meshDraws, false) - ArrayPool.Shared.Return(instancedDraws, false) - - // Upload shadow atlas uniforms to all shaders - match frameState.Camera with - | ValueSome cam -> - uploadShadowUniforms( - hasShadowCasters, - &forward, - &instanced, - &skinned, - shadowAtlas, - cam.Position, - atlasCfg.MaxCasters - ) - | ValueNone -> () - - // Build the per-frame scene bundle (lights + shadow result) + // Shadow pass: single-camera frames run one pass up front (frame-global gather, first + // camera). Multi-block frames run one pass per camera block at its BeginCamera in the + // forward loop instead. let shadowResult: ShadowResult voption = - if hasShadowCasters && shadowAtlas.ActiveCasterCount > 0 then - ValueSome { - Atlas = shadowAtlas.Fbo.Depth - ViewProjs = shadowAtlas.ViewProjs - UVOffsets = shadowAtlas.UVOffsets - ActiveCasterCount = shadowAtlas.ActiveCasterCount - TexelSize = 1.0f / float32 atlasCfg.Resolution - Biases = shadowAtlas.Biases - DirLightCastsShadows = - lights.DirLights.Count > 0 && lights.DirLights[0].CastsShadows - PointLightShadowIdx = pointShadowSlots - SpotLightShadowIdx = spotShadowSlots - } - else + if multiBlock then ValueNone + else + this.runFrameShadowPass( + gameCtx, + buffer, + &shadowResources, + &frameState + ) let mutable frame: ForwardFrame = { Lights = lights @@ -2441,15 +2752,25 @@ type ForwardPipelineBase instanced.LightsDirty <- true skinned.LightsDirty <- true + // Multi-camera-block frames: start the persistent defaults empty — the forward pass + // builds them in-order (between-block commands accumulate; each block resets to the + // defaults-so-far or inherits the running set at its BeginCamera), so live shading + // matches the block plan by construction. + if multiBlock then + LightBuffers.clear defaultLights + // Forward pass — dispatch all commands let mutable cameraActive = false let mutable currentCamera = Unchecked.defaultof let mutable shaderActive = false + // Running camera-block index into the block plan; advanced at each + // BeginCamera/BeginCameraConfig below (multi-block frames only). + let mutable blockIndex = -1 // Per-group shading scope (beginEffect/endEffect). ValueNone → default PBR path; // ValueSome shader → shade with the user shader. Reset on camera boundaries. let mutable activeEffect: Shader voption = ValueNone - let dispatchForwardPass() = + let dispatchForwardPass(sceneRT: RenderTexture2D voption) = for i = 0 to buffer.Count - 1 do match buffer[i] with // ── Camera management (inline — simple state toggles) ── @@ -2461,6 +2782,20 @@ type ForwardPipelineBase Raylib.EndMode3D() + // Multi-block frames: reset-or-inherit the lights, then render this block's + // shadow map (outside the camera and scene-RT scopes) before BeginMode3D. + if multiBlock then + this.beginShadowedBlock( + gameCtx, + buffer, + &shadowResources, + sceneRT, + plan, + &blockIndex, + cam, + &frame + ) + Raylib.BeginMode3D cam cameraActive <- true currentCamera <- cam @@ -2475,6 +2810,20 @@ type ForwardPipelineBase Raylib.EndMode3D() + // Multi-block frames: reset-or-inherit the lights, then render this block's + // shadow map (outside the camera and scene-RT scopes) before BeginMode3D. + if multiBlock then + this.beginShadowedBlock( + gameCtx, + buffer, + &shadowResources, + sceneRT, + plan, + &blockIndex, + cfg.Camera, + &frame + ) + applyCameraConfig(&cfg, gameCtx) Raylib.BeginMode3D cfg.Camera cameraActive <- true @@ -2611,6 +2960,11 @@ type ForwardPipelineBase | Command3D.AddSpotLight _ as cmd -> handleLightCommand(lights, cmd, &forward, &instanced, &skinned) + // Between-block commands also update the frame defaults, so a later block + // that resets sees them. + if multiBlock && not cameraActive then + LightScoping.apply defaultLights cmd + // ── Immediate mode: hand the callback the gathered scene data ── | Command3D.DrawImmediate action -> let savedCam = cameraActive @@ -2670,7 +3024,7 @@ type ForwardPipelineBase // attachment to the post-process context — OpenGL's depth buffer is directly sampleable, so // no separate geometry pre-pass is needed (unlike the MonoGame backend). match ppActions with - | ValueNone -> dispatchForwardPass() + | ValueNone -> dispatchForwardPass ValueNone | ValueSome actions -> // Use a depth-sampleable RT (custom FBO with a depth texture) when post-process effects // need to sample depth; otherwise a standard raylib RT (depth renderbuffer, cheaper). @@ -2682,7 +3036,7 @@ type ForwardPipelineBase Raylib.BeginTextureMode sceneRT Raylib.ClearBackground Color.Black - dispatchForwardPass() + dispatchForwardPass(ValueSome sceneRT) Raylib.EndTextureMode() let depth: Texture2D voption = diff --git a/src/Mibo.Raylib/Graphics3D/RenderBuffer3D.fs b/src/Mibo.Raylib/Graphics3D/RenderBuffer3D.fs index 0d2ff2c..1404c74 100644 --- a/src/Mibo.Raylib/Graphics3D/RenderBuffer3D.fs +++ b/src/Mibo.Raylib/Graphics3D/RenderBuffer3D.fs @@ -29,6 +29,7 @@ type RenderBuffer3D([] ?capacity: int) = let mutable clearCounter = 0 let mutable postProcessCount = 0 let mutable depthPostProcessCount = 0 + let mutable cameraBlockCount = 0 let ensureCapacity(needed: int) = if count + needed > items.Length then @@ -58,6 +59,13 @@ type RenderBuffer3D([] ?capacity: int) = /// member _.DepthPostProcessCount = depthPostProcessCount + /// + /// Number of BeginCamera/BeginCameraConfig commands added since the last + /// Clear. Lets a pipeline skip the per-camera-block plan (and its allocations) for + /// single-camera frames. + /// + member _.CameraBlockCount = cameraBlockCount + /// Gets the command at the specified index. member _.Item(i: int) = items[i] @@ -71,6 +79,8 @@ type RenderBuffer3D([] ?capacity: int) = | Command3D.PostProcessWithDepth _ -> postProcessCount <- postProcessCount + 1 depthPostProcessCount <- depthPostProcessCount + 1 + | Command3D.BeginCamera _ + | Command3D.BeginCameraConfig _ -> cameraBlockCount <- cameraBlockCount + 1 | _ -> () count <- count + 1 @@ -83,6 +93,7 @@ type RenderBuffer3D([] ?capacity: int) = count <- 0 postProcessCount <- 0 depthPostProcessCount <- 0 + cameraBlockCount <- 0 clearCounter <- clearCounter + 1 if clearCounter >= 300 then diff --git a/src/Mibo.Raylib/Graphics3D/SceneContext.fs b/src/Mibo.Raylib/Graphics3D/SceneContext.fs index fbe0118..539cd5b 100644 --- a/src/Mibo.Raylib/Graphics3D/SceneContext.fs +++ b/src/Mibo.Raylib/Graphics3D/SceneContext.fs @@ -60,6 +60,7 @@ module LightBuffers = } /// Default-capacity empty accumulator (3 dir / 8 point / 4 spot). + [] let defaults: LightBuffers = create 3 8 4 /// Resets all light accumulators to empty. @@ -69,6 +70,16 @@ module LightBuffers = lights.PointLights.Clear() lights.SpotLights.Clear() + /// Copies the contents of into , replacing whatever target held. + let inline copyInto (source: LightBuffers) (target: LightBuffers) = + target.Ambient <- source.Ambient + target.DirLights.Clear() + target.DirLights.AddRange(source.DirLights) + target.PointLights.Clear() + target.PointLights.AddRange(source.PointLights) + target.SpotLights.Clear() + target.SpotLights.AddRange(source.SpotLights) + // ───────────────────────────────────────────────────────────────────────────── // ShadowResult — the shadow pass output, threaded to SceneUpload and SceneContext // so a custom/user shader can opt into shadow sampling by name. diff --git a/src/Mibo.Raylib/Mibo.Raylib.fsproj b/src/Mibo.Raylib/Mibo.Raylib.fsproj index 44b439f..0fa9f5f 100644 --- a/src/Mibo.Raylib/Mibo.Raylib.fsproj +++ b/src/Mibo.Raylib/Mibo.Raylib.fsproj @@ -3,6 +3,9 @@ net10.0;net8.0 true + + + @@ -41,6 +44,7 @@ +