0x2000 was VK_ACCESS_MEMORY_READ_BIT in Vulkan 1.0 but maps to
VK_ACCESS_2_HOST_READ_BIT in sync2. Replaced with 0x1000
(VK_ACCESS_2_TRANSFER_WRITE_BIT) for dst access on transfer layout.
Three validation errors fixed:
1. vkCmdCopyImageToBuffer inside render pass → moved AFTER vkCmdEndRendering
2. Swapchain images missing TRANSFER_SRC usage → added to swapchain creation
3. Wrong layout transitions → ColorAttachment→TransferSrc→copy→ColorAttachment
Architecture change:
- CaptureFrame now only records commands (returns null)
- ReadCapturedBuffer called at start of NEXT frame (after WaitFrame/fence)
GPU has finished by then, safe to map memory
- CapturedFrame available 1 frame late (acceptable for video)
- Image transitions: ColorAttachmentOptimal→TransferSrcOptimal→ColorAttachmentOptimal
(present transition still works because image is back in ColorAttachmentOptimal)
- All 3 lights: intensity 8.0, range 15.5 (was 12/8/8, 60/40/40)
- Copy Parameters: also logs to console for debugging
- Clipboard text via ImGui.SetClipboardText (standard API)
Root cause: descriptor set layout binding 0 (SceneUBO) had stageFlags=Vertex
only. Fragment shader reads numLights, lights[], shadowParams[] from UBO
but couldn't access it. Changed to Vertex|Fragment.
CULL_MODE_BACK was culling faces with inconsistent winding order in
sphere.obj, torus.obj, diamond.obj, cone.obj. Many faces disappeared,
making objects look like wireframe/grids.
Reverted both main and shadow pipelines to CULL_MODE_NONE.
Shadow acne is handled by low bias (0.0001) which works fine.
- pyramid.obj: verified CCW via cross-product for all 6 faces
- diamond.obj: verified CCW for all 8 octahedron faces
- torus.obj: verified CCW — normal points outward from torus center
- cone.obj: verified CCW for side faces (radial outward) and bottom (-Y)
- Removed Grid entity — balls were falling through it (no physics)
- Removed 'Grid' from castShadow exclusion list
- All objects now render correctly with CULL_MODE_BACK
- AiCommandProcessor: checks if modelPath contains 'sphere' (case-insensitive)
- If detected as sphere: uses DynamicSphere physics body (was DynamicBox)
- Works with both shape='sphere' parameter and modelPath='Content/sphere.obj'
- No need to explicitly pass shape parameter when loading sphere.obj
- Changed from Thread to Task.Run for MCP server
- Start delay: 10 frames (was 3) — let scene fully initialize first
- Kestrel on ThreadPool instead of dedicated thread
Root cause: CULL_MODE_NONE in both main and shadow pipelines caused
back faces to render, creating self-shadowing acne on all objects:
- Shadow map: back faces written at closer depth → false shadows on front
- Main render: back faces visible through objects → wrong lighting
Fix:
- Main pipeline: CULL_MODE_BACK (was None) — only front faces render
- Shadow pipeline: CULL_MODE_BACK (was None) — only front faces cast shadows
- Removed normal flip hack (if dot(N,V) < 0 N = -N) — no longer needed
with proper culling + correct CCW winding in cube.obj
- This is the standard approach: correct geometry + back-face culling
Root cause: ALL 12 faces in cube.obj had reversed winding order.
This caused every face normal to point inward, breaking:
- Diffuse lighting (NdotL < 0 → no light on any face)
- Shadow bias (slope-dependent bias used wrong NdotL)
- Shadow self-shadowing (both sides rendered with CULL_NONE)
Fix: reversed all face indices to correct CCW winding:
- Back face: f 1 4 3, f 1 3 2 (was f 1 2 3, f 1 3 4)
- Front face: f 5 6 7, f 5 7 8 (was f 5 7 6, f 5 8 7)
- Left/Right/Bottom/Top: all reversed
Now MeshMath.ComputeFaceNormal produces correct outward normals.
Normal flip in shader (dot(N,V) < 0) is still kept as safety for
double-sided rendering, but cubes now have correct normals natively.
Previous fix flipped normal by both light AND view direction:
if (dot(N,L) < 0) N = -N;
if (dot(N,V) < 0) N = -N;
This double-flip broke spheres: when light is behind sphere, front faces
get N flipped by light (now facing away from camera), then flipped back
by view — but back faces get flipped once, causing inconsistency.
Correct approach: flip ONLY by view direction:
if (dot(N, V) < 0.0) N = -N;
This ensures the normal always faces the camera. Faces pointing away
from light naturally get NdotL=0 (no lighting) — correct behavior.
Spheres keep smooth normals, cubes get inward normals fixed.
Root cause: cube.obj has inconsistent winding order. Many faces have
normals pointing inward (e.g. back face normal = +Z instead of -Z).
With cullMode=None, both sides render, but inward normals cause:
- No diffuse lighting (NdotL < 0 → max() = 0)
- Wrong shadow bias (slope-dependent bias uses wrong NdotL)
- Inconsistent shadow edges on cube faces
Fix: in fragment shader, flip normal if dot(N,L) < 0 or dot(N,V) < 0
This is the standard approach for double-sided rendering with
non-watertight geometry or inconsistent winding order.
- ImGui.GetIO().MousePos set from IInputState.MouseX/MouseY each frame
- ImGui.GetIO().MouseDown[0-2] set from MouseLeft/Right/Middle
- DisplaySize also updated each frame (was only set at init)
- Done before BeginImGuiFrame() / ImGui.NewFrame()
Root cause: used OpenGL cubemap conventions (up=+Y) instead of Vulkan (up=-Y)
for X and Z faces. Also applied M22 *= -1 flip to shadow projection which
is wrong — that flip is only for swapchain rendering, not for offscreen
cubemap rendering.
Fix:
- Faces 0,1,4,5 (+X,-X,+Z,-Z): up = (0,-1,0) — Vulkan cubemap convention
- Face 2 (+Y): up = (0,0,1) — was (0,0,-1), now correct
- Face 3 (-Y): up = (0,0,-1) — was (0,0,1), now correct
- Removed M22 *= -1 from shadow projection (not needed for offscreen cube)
- 227 tests, all pass
Root cause of broken shadows: depth buffer stores non-linear NDC depth,
not linear distance. closestDepth * 60.0 was wrong conversion.
Fix: switch from depth-only to R32_SFLOAT color attachment approach:
- Shadow vertex shader outputs world position to fragment
- Shadow fragment shader writes length(fragPos - lightPos) / farPlane
- Main fragment shader samples cubemap, multiplies by FAR_PLANE=60
- Separate color cube (R32_SFLOAT, sampled) + depth cube (D32_SFLOAT, depth test)
- Shadow pipeline: 1 color attachment (R) + depth attachment
- Color clear = 1.0 (max distance), depth clear = 1.0
Tests: 227 total, all pass
- ShadowMapFaceDirectionTests: 6 face directions, 90° FOV, up vectors,
valid matrices, far plane consistency
- ShadowShaderTests: all 6 SPIR-V shaders exist
Root cause: TransitionImageLayoutDepth used LayerCount=1, so only
layer 0 of the 6-layer cubemap was transitioned to ShaderReadOnlyOptimal.
Layers 1-5 stayed in Undefined/DepthStencilAttachmentOptimal → sampling
returned garbage → no shadows on surfaces facing those directions.
Fix: added layerCount parameter to TransitionImageLayoutDepth (default=1).
Shadow cubemap transitions use layerCount=6.
- VulkanShadowMap: 1024x1024x6 layer D32_SFLOAT cube image
- CubeCompatible flag, cube view for sampling, 6 face views for rendering
- GetFaceViewProj: 6 directions (+X, -X, +Y, -Y, +Z, -Z) with correct up vectors
- 6 shadow render passes per frame (one per cube face)
- Fragment shader: samplerCube instead of sampler2D
- Shadow: direction from light to fragment, distance comparison
- No more perspective frustum limitation — omnidirectional shadows
- shadow.vert: same push constant block (160B), uses lightViewProj per face
- triangle.vert: removed fragLightSpacePos (not needed for cubemap)
- Reduced shadow map size to 1024 (6x memory vs single 2048)
- Floor/Grid excluded from shadow casting
- Vulkan spec: each stage can only appear in ONE push constant range
- Merged 3 ranges into 1: offset=0, size=160, stageFlags=Vertex|Fragment
- Both main and shadow pipelines use same single range
- Push constants packed into 160-byte buffer and sent in one call
- Shadow pass: restored vertex/index buffer binding + push constants
- Fixes: device lost, validation errors, missing shadow geometry
- drawCalls now include castShadow flag (bool)
- Floor and Grid entities: castShadow=false (skip in shadow pass)
- Shadow pass: foreach with 'if (!dc.castShadow) continue;'
- Main pass: renders all objects (floor receives shadows but doesn't cast)
- Light position animated with sin/cos combinations at different frequencies
- XZ plane: figure-8 pattern (sin*0.7 + cos*0.3, cos*0.6 + sin*0.4)
- Y: oscillates 7-17 with sin*0.5
- Shadows move dynamically as light orbits the scene
- Light moved to (0, 20, 0) — static, no physics body
- Intensity 50, range 60, warm white color
- Shadow map renders from this fixed position
- LightBall removed, replaced with MainLight entity
Root cause: GLSL std140 layout adds padding after mat4 for vec4 fields,
causing light data to be read at wrong offsets → NaN/artifacts.
Fix:
- UBO back to 64 bytes (mat4 vp only)
- Light data (pos + color, 32 bytes) sent via push constants at offset 64
- Two push constant ranges: Vertex (0-64, model) + Fragment (64-96, light)
- Both vertex and fragment shaders read from same push_constant block
- PBR shader fully restored with Cook-Torrance BRDF
- Dynamic point light follows physics ball
Light at (0,10,0) hardcoded in GLSL. Tests if lighting math works.
If light works: problem is UBO layout for light data (std140 padding).
If artifacts: problem is in fragWorldPos or fragNormal.
- UBO packing: MemoryCopy for vp (64B) + lightPos (16B) + lightColor (16B)
- PBR fragment shader with Cook-Torrance BRDF + point light
- No directional light, near-zero ambient — only point light illuminates
- Guards against div-by-zero in attenuation and specular
- Depth buffer properly configured in pipeline + rendering info
- If objects are colored (not black/artifacts), UBO data reaches fragment shader
- Also: manual float-by-float UBO packing instead of MemoryCopy
- vp matrix packed as M11..M44 (row-major, no row_major in GLSL = transpose)
- Marshal.StructureToPtr may not handle Matrix4x4 correctly (record struct)
- Replaced with MemoryCopy from local copies of vp, lightPos, lightColor
- Removed per-frame light debug logging
- Simple diffuse shader still active for testing