OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_texture_atlas.gd

167 lines · gd ref:
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)