extends SceneTree
## GATE: can we stream tiles into an ATLAS TEXTURE and read them from a compute
## shader? Proven in isolation, before any of it goes near dc-terrain's pipeline.
##
## WHY THIS EXISTS. The plan is to stop re-uploading a 90 MB mip pyramid every
## time the streaming window moves, and instead blit one tile (~266 KB) into an
## atlas. That rests on three API claims taken from the headers, none of which
## had been executed:
##
## 1. texture_update() is WHOLE-LAYER — no offset, no extent. If that were the
## only path, a 4098^2 atlas would cost a 67 MB update per tile and the
## whole idea dies.
## 2. texture_copy() takes Vector3 from_pos/to_pos/size, so a SUB-REGION blit
## from a small staging texture into the atlas should work. That is the
## thing the design depends on.
## 3. A compute shader can texelFetch the atlas through UNIFORM_TYPE_IMAGE.
##
## THE CONTROL IS A DELIBERATELY WRONG DESTINATION. After blitting tile B to its
## correct offset, the gate also checks a location that should still hold tile A.
## If everything read back as B, the copy would be clobbering the atlas and a
## test that only checked B would call that success.
##
## godot --headless --path . --script res://scenes/test/gate_texture_atlas.gd
const TILE := 64 # small, so the gate is fast; the mechanism is identical
const ATLAS := 256 # 4x4 tiles
const SHADER := """
#version 450
layout(local_size_x = 8, local_size_y = 8) in;
layout(set = 0, binding = 0, r32f) uniform readonly image2D atlas;
layout(set = 0, binding = 1, std430) writeonly buffer Out { float vals[]; };
layout(push_constant) uniform PC { int probe_n; } pc;
void main() {
ivec2 c = ivec2(gl_GlobalInvocationID.xy);
if (c.x >= pc.probe_n || c.y >= pc.probe_n) return;
// Probe on a coarse lattice across the whole atlas.
ivec2 t = c * (256 / pc.probe_n);
vals[c.y * pc.probe_n + c.x] = imageLoad(atlas, t).r;
}
"""
var _fails := 0
func ck(name: String, got, want) -> void:
var ok: bool = (typeof(got) == TYPE_FLOAT and abs(float(got) - float(want)) < 1e-4) \
or str(got) == str(want)
if ok:
print(" ok %-44s %s" % [name, str(got)])
else:
print(" FAIL %-44s got %s want %s" % [name, str(got), str(want)])
_fails += 1
func _make_tile(value: float) -> PackedByteArray:
var b := PackedByteArray()
b.resize(TILE * TILE * 4)
for i in range(TILE * TILE):
b.encode_float(i * 4, value)
return b
func _init() -> void:
var rd := RenderingServer.create_local_rendering_device()
if rd == null:
print("no local RenderingDevice (no Vulkan?) — cannot run")
quit(3)
return
# --- atlas + staging -----------------------------------------------------
var fmt := RDTextureFormat.new()
fmt.format = RenderingDevice.DATA_FORMAT_R32_SFLOAT
fmt.width = ATLAS; fmt.height = ATLAS; fmt.depth = 1
fmt.array_layers = 1; fmt.mipmaps = 1
fmt.texture_type = RenderingDevice.TEXTURE_TYPE_2D
fmt.usage_bits = RenderingDevice.TEXTURE_USAGE_STORAGE_BIT \
| RenderingDevice.TEXTURE_USAGE_CAN_COPY_TO_BIT \
| RenderingDevice.TEXTURE_USAGE_CAN_COPY_FROM_BIT
var atlas := rd.texture_create(fmt, RDTextureView.new(), [])
var sfmt := RDTextureFormat.new()
sfmt.format = RenderingDevice.DATA_FORMAT_R32_SFLOAT
sfmt.width = TILE; sfmt.height = TILE; sfmt.depth = 1
sfmt.array_layers = 1; sfmt.mipmaps = 1
sfmt.texture_type = RenderingDevice.TEXTURE_TYPE_2D
# ⚠ CAN_UPDATE_BIT is required for texture_update(), and its absence is not
# a creation error — texture_create succeeds and the UPDATE fails later.
sfmt.usage_bits = RenderingDevice.TEXTURE_USAGE_CAN_UPDATE_BIT \
| RenderingDevice.TEXTURE_USAGE_CAN_COPY_TO_BIT \
| RenderingDevice.TEXTURE_USAGE_CAN_COPY_FROM_BIT
var staging := rd.texture_create(sfmt, RDTextureView.new(), [])
ck("atlas created", atlas.is_valid(), true)
ck("staging created", staging.is_valid(), true)
# --- stream two tiles to different offsets -------------------------------
# tile A -> (0,0), tile B -> (TILE,0). One texture_update of the SMALL
# staging texture each time, then a sub-region texture_copy into the atlas.
var eA := rd.texture_update(staging, 0, _make_tile(11.0))
var cA := rd.texture_copy(staging, atlas, Vector3(0, 0, 0), Vector3(0, 0, 0),
Vector3(TILE, TILE, 1), 0, 0, 0, 0)
ck("update staging (tile A)", eA, OK)
ck("copy A into atlas at (0,0)", cA, OK)
var eB := rd.texture_update(staging, 0, _make_tile(22.0))
var cB := rd.texture_copy(staging, atlas, Vector3(0, 0, 0), Vector3(TILE, 0, 0),
Vector3(TILE, TILE, 1), 0, 0, 0, 0)
ck("update staging (tile B)", eB, OK)
ck("copy B into atlas at (%d,0)" % TILE, cB, OK)
# --- read it back from a COMPUTE SHADER ----------------------------------
var src := RDShaderSource.new()
src.set_stage_source(RenderingDevice.SHADER_STAGE_COMPUTE, SHADER)
var spirv := rd.shader_compile_spirv_from_source(src)
var err := spirv.get_stage_compile_error(RenderingDevice.SHADER_STAGE_COMPUTE)
if not err.is_empty():
print(" FAIL shader: ", err)
_fails += 1
quit(1)
return
var shader := rd.shader_create_from_spirv(spirv)
var probe := 4
var out_bytes := probe * probe * 4
var zero := PackedByteArray(); zero.resize(out_bytes)
var outbuf := rd.storage_buffer_create(out_bytes, zero)
var u0 := RDUniform.new()
u0.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
u0.binding = 0
u0.add_id(atlas)
var u1 := RDUniform.new()
u1.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
u1.binding = 1
u1.add_id(outbuf)
var uset := rd.uniform_set_create([u0, u1], shader, 0)
# Exactly the shader's push-constant size: one int. Godot checks it strictly
# ("requires (4) bytes ... supplied (16)") rather than padding.
var pc := PackedByteArray(); pc.resize(4); pc.encode_s32(0, probe)
var pipe := rd.compute_pipeline_create(shader)
var cl := rd.compute_list_begin()
rd.compute_list_bind_compute_pipeline(cl, pipe)
rd.compute_list_bind_uniform_set(cl, uset, 0)
rd.compute_list_set_push_constant(cl, pc, pc.size())
rd.compute_list_dispatch(cl, 1, 1, 1)
rd.compute_list_end()
rd.submit()
rd.sync()
var got := rd.buffer_get_data(outbuf).to_float32_array()
# probe lattice step is ATLAS/probe = 64, so column 0 lands in tile A and
# column 1 lands in tile B.
ck("shader reads tile A at (0,0)", got[0], 11.0)
ck("shader reads tile B at (64,0)", got[1], 22.0)
print("\n CONTROL: the second copy must NOT have clobbered tile A")
ck("tile A still 11 after B was written", got[0], 11.0)
ck("untouched atlas region is still 0", got[2], 0.0)
print("")
if _fails > 0:
print("gate_texture_atlas: %d FAILED" % _fails)
quit(1)
else:
print("gate_texture_atlas: sub-region streaming into an atlas WORKS")
quit(0)