Fix terrain height and native frame capture
Docs Deploy / Build and Deploy MkDocs (push) Successful in 40s

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.
This commit is contained in:
2026-10-11 17:37:48 +04:00
parent 7b7324a413
commit 100f656b08
9 changed files with 5409 additions and 196 deletions
@@ -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)."