From 100f656b089898e4ffef48871f5ed5ea068d54b6 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 11 Oct 2026 17:37:48 +0400 Subject: Fix terrain height and native frame capture Use raw world-space terrain heights throughout mesh, camera-floor, shadow, and sun-ray paths while preserving the projection far-plane conversion. Complete the verified selected-camera readback flow with frozen phase sampling, capture tooling, and documentation. --- .../convert-viewer-readback.ps1 | 346 +++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 tools/native-frame-capture/convert-viewer-readback.ps1 (limited to 'tools/native-frame-capture/convert-viewer-readback.ps1') diff --git a/tools/native-frame-capture/convert-viewer-readback.ps1 b/tools/native-frame-capture/convert-viewer-readback.ps1 new file mode 100644 index 0000000..3bafe14 --- /dev/null +++ b/tools/native-frame-capture/convert-viewer-readback.ps1 @@ -0,0 +1,346 @@ +[CmdletBinding(DefaultParameterSetName = 'Convert')] +param( + [Parameter(Mandatory = $true, ParameterSetName = 'Convert')] + [string]$InputRaw, + + [Parameter(Mandatory = $true, ParameterSetName = 'Convert')] + [string]$CameraJson, + + [Parameter(Mandatory = $true, ParameterSetName = 'Convert')] + [ValidateSet('37', '43', '44', '50')] + [int]$VkFormat, + + [Parameter(Mandatory = $true, ParameterSetName = 'Convert')] + [string]$OutputPng, + + [Parameter(Mandatory = $true, ParameterSetName = 'SelfTest')] + [switch]$SelfTest +) + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +function Assert-ReadbackByteLength { + param( + [long]$ActualLength, + [int]$Width, + [int]$Height + ) + + $maximumLength = [long]64 * 1024 * 1024 + if ($Width -le 0 -or $Height -le 0) { + throw [System.IO.InvalidDataException]::new('Readback dimensions must be positive.') + } + $expectedLength = [long]$Width * [long]$Height * 4 + if ($expectedLength -gt $maximumLength) { + throw [System.IO.InvalidDataException]::new("Readback exceeds the 64 MiB limit: $expectedLength bytes.") + } + if ($ActualLength -ne $expectedLength) { + throw [System.IO.InvalidDataException]::new( + "Readback must contain exactly width * height * 4 bytes; got $ActualLength for ${Width}x${Height} (expected $expectedLength)." + ) + } +} + +function ConvertTo-ViewportCoordinate { + param( + [object]$Value, + [string]$Name + ) + + try { + $number = [System.Convert]::ToDecimal($Value, [System.Globalization.CultureInfo]::InvariantCulture) + } + catch { + throw "Camera JSON viewport $Name must be an integer." + } + if ($number -ne [decimal]::Truncate($number) -or + $number -lt [int]::MinValue -or $number -gt [int]::MaxValue) { + throw "Camera JSON viewport $Name must be an in-range 32-bit integer." + } + return [int]$number +} + +function Convert-ReadbackToBgra { + param( + [byte[]]$Source, + [int]$Format, + [int]$Width, + [int]$Height + ) + + Assert-ReadbackByteLength -ActualLength $Source.Length -Width $Width -Height $Height + + # VK_FORMAT_R8G8B8A8_* (37, 43) needs R/B swapped for System.Drawing's + # little-endian Format32bppArgb storage. VK_FORMAT_B8G8R8A8_* (44, 50) + # already has the byte order expected by that bitmap format. + if ($Format -eq 44 -or $Format -eq 50) { + return ,$Source + } + + $converted = New-Object byte[] $Source.Length + for ($offset = 0; $offset -lt $Source.Length; $offset += 4) { + $converted[$offset] = $Source[$offset + 2] + $converted[$offset + 1] = $Source[$offset + 1] + $converted[$offset + 2] = $Source[$offset] + $converted[$offset + 3] = $Source[$offset + 3] + } + + return ,$converted +} + +function Assert-ViewportWithinCapture { + param( + [int]$Left, + [int]$Top, + [int]$Right, + [int]$Bottom, + [int]$CaptureWidth, + [int]$CaptureHeight + ) + + if ($CaptureWidth -le 0 -or $CaptureHeight -le 0) { + throw [System.IO.InvalidDataException]::new('Camera JSON pixel_capture dimensions must be positive.') + } + if ($Left -lt 0 -or $Top -lt 0 -or $Right -gt $CaptureWidth -or $Bottom -gt $CaptureHeight) { + throw [System.IO.InvalidDataException]::new('Camera JSON viewport must fit within the full pixel_capture image bounds.') + } +} + +function Save-BgraPngToStream { + param( + [byte[]]$Pixels, + [int]$Width, + [int]$Height, + [System.IO.Stream]$Stream + ) + + $bitmap = New-Object System.Drawing.Bitmap( + $Width, + $Height, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb + ) + $rectangle = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height) + $bitmapData = $null + try { + $bitmapData = $bitmap.LockBits( + $rectangle, + [System.Drawing.Imaging.ImageLockMode]::WriteOnly, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb + ) + + $rowBytes = $Width * 4 + if ([Math]::Abs($bitmapData.Stride) -lt $rowBytes) { + throw 'Bitmap stride is shorter than a pixel row.' + } + + for ($row = 0; $row -lt $Height; $row++) { + $destination = [IntPtr]::Add($bitmapData.Scan0, $row * $bitmapData.Stride) + [System.Runtime.InteropServices.Marshal]::Copy( + $Pixels, + $row * $rowBytes, + $destination, + $rowBytes + ) + } + + $bitmap.UnlockBits($bitmapData) + $bitmapData = $null + $bitmap.Save($Stream, [System.Drawing.Imaging.ImageFormat]::Png) + } + finally { + if ($null -ne $bitmapData) { + $bitmap.UnlockBits($bitmapData) + } + $bitmap.Dispose() + } +} + +function Get-CameraExtentFromObject { + param([object]$Camera) + + $camera = $Camera + if ($camera.schema -ne 'fparkan-legacy-camera-v1' -or $null -eq $camera.viewport -or $camera.viewport.Count -ne 4) { + throw 'Camera JSON must be fparkan-legacy-camera-v1 and contain a four-value viewport.' + } + + $left = ConvertTo-ViewportCoordinate -Value $camera.viewport[0] -Name 'left' + $top = ConvertTo-ViewportCoordinate -Value $camera.viewport[1] -Name 'top' + $right = ConvertTo-ViewportCoordinate -Value $camera.viewport[2] -Name 'right' + $bottom = ConvertTo-ViewportCoordinate -Value $camera.viewport[3] -Name 'bottom' + $widthLong = [long]$right - [long]$left + $heightLong = [long]$bottom - [long]$top + if ($widthLong -gt [int]::MaxValue -or $heightLong -gt [int]::MaxValue) { + throw 'Camera JSON viewport extent exceeds 32-bit dimensions.' + } + $width = [int]$widthLong + $height = [int]$heightLong + if ($width -le 0 -or $height -le 0) { + throw 'Camera JSON viewport has an empty or inverted extent.' + } + + $expectedBytes = [long]$width * [long]$height * 4 + if ($expectedBytes -gt ([long]64 * 1024 * 1024)) { + throw "Camera JSON viewport exceeds the 64 MiB readback limit: $expectedBytes bytes." + } + if ($null -ne $camera.pixel_capture) { + $captureWidth = ConvertTo-ViewportCoordinate -Value $camera.pixel_capture.width -Name 'pixel_capture.width' + $captureHeight = ConvertTo-ViewportCoordinate -Value $camera.pixel_capture.height -Name 'pixel_capture.height' + Assert-ViewportWithinCapture -Left $left -Top $top -Right $right -Bottom $bottom -CaptureWidth $captureWidth -CaptureHeight $captureHeight + } + + return [pscustomobject]@{ Width = $width; Height = $height } +} + +function Get-CameraExtent { + param([string]$Path) + + $camera = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json + return Get-CameraExtentFromObject -Camera $camera +} + +function Invoke-SelfTest { + $rgba = [byte[]]@( + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255 + ) + $expectedBgra = [byte[]]@( + 0, 0, 255, 255, 0, 255, 0, 255, + 255, 0, 0, 255, 255, 255, 255, 255 + ) + + $rgbaResult = Convert-ReadbackToBgra -Source $rgba -Format 37 -Width 2 -Height 2 + $bgraResult = Convert-ReadbackToBgra -Source $expectedBgra -Format 44 -Width 2 -Height 2 + for ($index = 0; $index -lt $expectedBgra.Length; $index++) { + if ($rgbaResult[$index] -ne $expectedBgra[$index] -or $bgraResult[$index] -ne $expectedBgra[$index]) { + throw 'Self-check failed: RGBA/BGRA channel mapping.' + } + } + + $mismatchRejected = $false + try { + Assert-ReadbackByteLength -ActualLength 15 -Width 2 -Height 2 + } + catch [System.IO.InvalidDataException] { + $mismatchRejected = $true + } + if (-not $mismatchRejected) { + throw 'Self-check failed: wrong byte length was accepted.' + } + + Assert-ReadbackByteLength -ActualLength ([long]64 * 1024 * 1024) -Width 4096 -Height 4096 + $oversizeRejected = $false + try { + Assert-ReadbackByteLength -ActualLength ([long]64 * 1024 * 1024) -Width 4097 -Height 4096 + } + catch [System.IO.InvalidDataException] { + $oversizeRejected = $true + } + if (-not $oversizeRejected) { + throw 'Self-check failed: readback over the 64 MiB boundary was accepted.' + } + + foreach ($invalidCoordinate in @([double]1.5, [long]2147483648)) { + $coordinateRejected = $false + try { + ConvertTo-ViewportCoordinate -Value $invalidCoordinate -Name 'self-check' + } + catch { + $coordinateRejected = $true + } + if (-not $coordinateRejected) { + throw 'Self-check failed: fractional or out-of-range viewport coordinate was accepted.' + } + } + + # A crop can be smaller than the full native PNG and start away from (0,0). + # Raw viewer readback is the crop only, so its dimensions stay 4x4 here. + $cropCamera = [pscustomobject]@{ + schema = 'fparkan-legacy-camera-v1' + viewport = @(4, 6, 8, 10) + pixel_capture = [pscustomobject]@{ width = 12; height = 16 } + } + $cropExtent = Get-CameraExtentFromObject -Camera $cropCamera + if ($cropExtent.Width -ne 4 -or $cropExtent.Height -ne 4) { + throw 'Self-check failed: cropped readback extent was not derived from the viewport rectangle.' + } + Assert-ReadbackByteLength -ActualLength 64 -Width 4 -Height 4 + $cropOutsideCaptureRejected = $false + try { + $cropCamera.viewport = @(4, 6, 13, 10) + Get-CameraExtentFromObject -Camera $cropCamera | Out-Null + } + catch [System.IO.InvalidDataException] { + $cropOutsideCaptureRejected = $true + } + if (-not $cropOutsideCaptureRejected) { + throw 'Self-check failed: viewport extending beyond the full pixel_capture image was accepted.' + } + + $stream = New-Object System.IO.MemoryStream + $decoded = $null + try { + Save-BgraPngToStream -Pixels $expectedBgra -Width 2 -Height 2 -Stream $stream + $stream.Position = 0 + $decoded = [System.Drawing.Bitmap]::new($stream) + $expectedColors = @( + [System.Drawing.Color]::Red, + [System.Drawing.Color]::FromArgb(255, 0, 255, 0), + [System.Drawing.Color]::Blue, + [System.Drawing.Color]::White + ) + $points = @(@(0, 0), @(1, 0), @(0, 1), @(1, 1)) + for ($index = 0; $index -lt $points.Count; $index++) { + $point = $points[$index] + $actualColor = $decoded.GetPixel($point[0], $point[1]) + if ($actualColor.ToArgb() -ne $expectedColors[$index].ToArgb()) { + throw "Self-check failed: PNG pixel $index expected $($expectedColors[$index]) but read $actualColor." + } + } + } + finally { + if ($null -ne $decoded) { $decoded.Dispose() } + $stream.Dispose() + } + + 'Self-check passed (2x2 RGBA/BGRA PNG, byte-length/64 MiB limits, viewport-coordinate bounds, and cropped pixel_capture bounds).' +} + +if ($SelfTest) { + Invoke-SelfTest + exit 0 +} + +$repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$targetRoot = [System.IO.Path]::GetFullPath((Join-Path $repositoryRoot 'target')) +$rawPath = (Resolve-Path -LiteralPath $InputRaw).Path +$cameraPath = (Resolve-Path -LiteralPath $CameraJson).Path + +if (-not (Test-Path -LiteralPath $rawPath -PathType Leaf)) { throw "Raw readback is not a file: $rawPath" } +if (-not (Test-Path -LiteralPath $cameraPath -PathType Leaf)) { throw "Camera JSON is not a file: $cameraPath" } + +$outputPath = [System.IO.Path]::GetFullPath($OutputPng) +$targetPrefix = $targetRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar +if (-not $outputPath.StartsWith($targetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Output must be beneath the repository target directory: $targetRoot" +} +if ([System.IO.Path]::GetExtension($outputPath) -ine '.png') { throw 'Output filename must end in .png.' } +if (Test-Path -LiteralPath $outputPath) { throw "Refusing to overwrite existing output: $outputPath" } +$outputDirectory = [System.IO.Path]::GetDirectoryName($outputPath) +if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) { + throw "Output directory does not exist: $outputDirectory" +} + +$extent = Get-CameraExtent -Path $cameraPath +$rawInfo = Get-Item -LiteralPath $rawPath +Assert-ReadbackByteLength -ActualLength $rawInfo.Length -Width $extent.Width -Height $extent.Height +$source = [System.IO.File]::ReadAllBytes($rawPath) +$pixels = Convert-ReadbackToBgra -Source $source -Format $VkFormat -Width $extent.Width -Height $extent.Height +$stream = [System.IO.File]::Open($outputPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) +try { + Save-BgraPngToStream -Pixels $pixels -Width $extent.Width -Height $extent.Height -Stream $stream +} +finally { + $stream.Dispose() +} +"Wrote $($extent.Width)x$($extent.Height) PNG to $outputPath (VkFormat $VkFormat)." -- cgit v1.2.3