aboutsummaryrefslogtreecommitdiff
path: root/tools/native-frame-capture/convert-viewer-readback.ps1
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-11 16:37:48 +0300
committerValentin Popov <valentin@popov.link>2026-10-11 16:37:48 +0300
commit100f656b089898e4ffef48871f5ed5ea068d54b6 (patch)
tree46bede940909108a40fef0bcca6ab7a2795c3ed0 /tools/native-frame-capture/convert-viewer-readback.ps1
parent7b7324a413449b21b1d29f77a36abfe282c045e6 (diff)
downloadfparkan-100f656b089898e4ffef48871f5ed5ea068d54b6.tar.xz
fparkan-100f656b089898e4ffef48871f5ed5ea068d54b6.zip
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.
Diffstat (limited to 'tools/native-frame-capture/convert-viewer-readback.ps1')
-rw-r--r--tools/native-frame-capture/convert-viewer-readback.ps1346
1 files changed, 346 insertions, 0 deletions
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)."