perf(goal): merge goal visuals into one ArrayMesh

Goal._build_visuals() built 10 MeshInstance3D nodes across 4 materials
(1 pocket + 1 net + 4 bezel-ring + 4 rim-ring boxes) per goal. Replaced
with a single MeshInstance3D wrapping one ArrayMesh with 4 SurfaceTool-
committed surfaces (pocket, net, bezel, rim), one material per surface
via surface_set_material — 10 nodes down to 1, same 4 materials.

Kept 4 surfaces rather than collapsing further: the rim is a tuned
team-tinted emitter, the net carries its own discard shader, and the
pocket/bezel differ in albedo/metallic/roughness. Merging those into a
shared material would be a visible regression, not a free win.

Added a local box-to-SurfaceTool helper (6 quads via arena_boundary.gd's
_add_quad/_add_tri winding-correction trick, copied in rather than
shared since that file's geometry is collision-adjacent). The pocket's
old cull_mode = CULL_FRONT trick is replaced by emitting its geometry
with inverted winding; the net's cull_front stays material-driven since
goal_net.gdshader's own render_mode depends on that winding convention.

Verified in the editor: both team-tinted goals render an intact pocket,
net, bezel and glowing rim with no backface/winding artifacts, and the
headless free_play smoke test still runs clean.
This commit is contained in:
Josh Creek
2026-08-04 17:48:39 +01:00
parent b01d5a68d9
commit 0603452264
2 changed files with 105 additions and 29 deletions
+105 -28
View File
@@ -54,48 +54,125 @@ func _build_visuals() -> void:
var half := Vector2(mouth.x, mouth.y) / 2.0
var tint: Color = TEAM_COLORS[team % TEAM_COLORS.size()]
# Pocket shell, seen from the inside (hence CULL_FRONT) so it reads as a
# void carved into the hull rather than a box stuck onto it.
var pocket := _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9)
pocket.cull_mode = BaseMaterial3D.CULL_FRONT
_add_box(
Vector3(mouth.x + POCKET_CLEARANCE * 2.0, mouth.y + POCKET_CLEARANCE * 2.0, POCKET_DEPTH),
Vector3(0, 0, POCKET_DEPTH / 2.0), pocket)
# One ArrayMesh, four surfaces (pocket, net, bezel, rim). They stay separate
# surfaces rather than sharing materials because they aren't visually
# interchangeable: the rim is a team-tinted emitter at a tuned energy, the
# net carries its own discard-based shader, and the pocket/bezel differ in
# albedo/metallic/roughness. Merging those would be a visual regression,
# not a free draw-call win — this still takes the goal from 10 nodes/4
# materials down to 1 node/4 materials.
var mesh := ArrayMesh.new()
# The net is the same trick: a box viewed from inside gives a five-sided
# pocket of netting from one mesh, instead of a flat panel across the back.
_add_box(Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88),
Vector3(0, 0, POCKET_DEPTH / 2.0), _net_material(tint))
# Pocket shell. Built with inverted winding so it reads correctly from the
# inside (a void carved into the hull) without needing a material-level
# CULL_FRONT override.
var pocket_st := SurfaceTool.new()
pocket_st.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_box_to_surface(pocket_st,
Vector3(mouth.x + POCKET_CLEARANCE * 2.0, mouth.y + POCKET_CLEARANCE * 2.0, POCKET_DEPTH),
Vector3(0, 0, POCKET_DEPTH / 2.0), true)
pocket_st.commit(mesh)
mesh.surface_set_material(0, _surface(Color(0.016, 0.018, 0.026), 0.2, 0.9))
# The net is the same trick, but the "seen from inside" flip is baked into
# goal_net.gdshader's own `render_mode cull_front` (and its FRONT_FACING
# normal flip), so this geometry must keep standard, non-inverted winding
# to match what that shader expects.
var net_st := SurfaceTool.new()
net_st.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_box_to_surface(net_st, Vector3(mouth.x, mouth.y, POCKET_DEPTH * 0.88),
Vector3(0, 0, POCKET_DEPTH / 2.0))
net_st.commit(mesh)
mesh.surface_set_material(1, _net_material(tint))
# Bezel lines the opening (inset into the wall); the rim sits flush with
# the wall face and carries the team colour.
_add_frame_ring(half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0,
_surface(Color(0.05, 0.055, 0.07), 0.75, 0.32))
_add_frame_ring(half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0, _emissive(tint))
var bezel_st := SurfaceTool.new()
bezel_st.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_frame_ring(bezel_st, half, BEZEL_THICKNESS, BEZEL_DEPTH, BEZEL_DEPTH / 2.0)
bezel_st.commit(mesh)
mesh.surface_set_material(2, _surface(Color(0.05, 0.055, 0.07), 0.75, 0.32))
var rim_st := SurfaceTool.new()
rim_st.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_frame_ring(rim_st, half, RIM_THICKNESS, RIM_DEPTH, -RIM_DEPTH / 2.0)
rim_st.commit(mesh)
mesh.surface_set_material(3, _emissive(tint))
var visuals := MeshInstance3D.new()
visuals.name = "Visuals"
visuals.mesh = mesh
visuals.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(visuals)
# Four bars around the mouth. The uprights run the full outer height so each
# corner is covered exactly once.
func _add_frame_ring(
half: Vector2, thickness: float, depth: float, z: float, material: Material
st: SurfaceTool, half: Vector2, thickness: float, depth: float, z: float
) -> void:
for sx in [-1.0, 1.0]:
_add_box(Vector3(thickness, (half.y + thickness) * 2.0, depth),
Vector3(sx * (half.x + thickness / 2.0), 0.0, z), material)
_add_box_to_surface(st, Vector3(thickness, (half.y + thickness) * 2.0, depth),
Vector3(sx * (half.x + thickness / 2.0), 0.0, z))
for sy in [-1.0, 1.0]:
_add_box(Vector3(half.x * 2.0, thickness, depth),
Vector3(0.0, sy * (half.y + thickness / 2.0), z), material)
_add_box_to_surface(st, Vector3(half.x * 2.0, thickness, depth),
Vector3(0.0, sy * (half.y + thickness / 2.0), z))
func _add_box(size: Vector3, pos: Vector3, material: Material) -> void:
var mesh := BoxMesh.new()
mesh.size = size
var instance := MeshInstance3D.new()
instance.mesh = mesh
instance.position = pos
instance.material_override = material
instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(instance)
# Emits a box's 6 faces as quads into st, centered at pos. With invert, face
# winding/normals are flipped — used for the pocket so it renders correctly
# as seen from inside without a material-level CULL_FRONT override.
func _add_box_to_surface(
st: SurfaceTool, size: Vector3, pos: Vector3, invert: bool = false
) -> void:
var h := size / 2.0
var n := -1.0 if invert else 1.0
# The 8 corners of the box, named by the sign of each axis.
var nnn := pos + Vector3(-h.x, -h.y, -h.z)
var nnp := pos + Vector3(-h.x, -h.y, h.z)
var npn := pos + Vector3(-h.x, h.y, -h.z)
var npp := pos + Vector3(-h.x, h.y, h.z)
var pnn := pos + Vector3(h.x, -h.y, -h.z)
var pnp := pos + Vector3(h.x, -h.y, h.z)
var ppn := pos + Vector3(h.x, h.y, -h.z)
var ppp := pos + Vector3(h.x, h.y, h.z)
_add_quad(st, pnn, Vector3(n, 0, 0), ppn, Vector3(n, 0, 0), ppp, Vector3(n, 0, 0), pnp, Vector3(n, 0, 0))
_add_quad(st, nnn, Vector3(-n, 0, 0), nnp, Vector3(-n, 0, 0), npp, Vector3(-n, 0, 0), npn, Vector3(-n, 0, 0))
_add_quad(st, npn, Vector3(0, n, 0), npp, Vector3(0, n, 0), ppp, Vector3(0, n, 0), ppn, Vector3(0, n, 0))
_add_quad(st, nnn, Vector3(0, -n, 0), pnn, Vector3(0, -n, 0), pnp, Vector3(0, -n, 0), nnp, Vector3(0, -n, 0))
_add_quad(st, nnp, Vector3(0, 0, n), pnp, Vector3(0, 0, n), ppp, Vector3(0, 0, n), npp, Vector3(0, 0, n))
_add_quad(st, nnn, Vector3(0, 0, -n), npn, Vector3(0, 0, -n), ppn, Vector3(0, 0, -n), pnn, Vector3(0, 0, -n))
# Quad a-b-c-d with per-vertex normals, wound so the front faces the normals
# (Godot front faces wind clockwise when seen from the normal side). Copied
# from arena_boundary.gd's _add_quad/_add_tri rather than shared, since that
# file's geometry is collision-adjacent and not worth coupling to.
func _add_quad(
st: SurfaceTool,
a: Vector3, na: Vector3, b: Vector3, nb: Vector3,
c: Vector3, nc: Vector3, d: Vector3, nd: Vector3
) -> void:
if (b - a).cross(c - a).dot(na + nb + nc + nd) < 0.0:
_add_tri(st, a, na, b, nb, c, nc)
_add_tri(st, a, na, c, nc, d, nd)
else:
_add_tri(st, a, na, d, nd, c, nc)
_add_tri(st, a, na, c, nc, b, nb)
func _add_tri(
st: SurfaceTool,
a: Vector3, na: Vector3, b: Vector3, nb: Vector3, c: Vector3, nc: Vector3
) -> void:
st.set_normal(na)
st.add_vertex(a)
st.set_normal(nb)
st.add_vertex(b)
st.set_normal(nc)
st.add_vertex(c)
func _surface(albedo: Color, metallic: float, roughness: float) -> StandardMaterial3D:
-1
View File
@@ -26,7 +26,6 @@ Bugs found in an adversarial review. None are gameplay- or physics-affecting, so
## Performance
- [ ] Shared per-team materials instead of `Ship._apply_team_color()` allocating a fresh `StandardMaterial3D` and assigning it as `material_override` (and running at least twice per ship — once from `_ready`, once from the `team` setter). Removes the allocation and lets same-team ships batch.
- [ ] Merge each goal's visuals into one `ArrayMesh` with a hull surface and a net surface — currently 11 `MeshInstance3D`s and 4 materials per goal, ~22 draw calls for a static prop. `arena_boundary.gd:333` already demonstrates the `SurfaceTool` technique in this codebase.
- [ ] Merge the four non-tinted ship meshes (Hull/Canopy/EngineGlowL/R) into one — 6 draw calls per ship down to 3. Irrelevant at 1v1; 36 calls before VFX at 3v3.
- [ ] Name collision layers in `project.godot` and assign them — nothing configures `collision_layer`/`collision_mask` today, so every body tests against every other.
- [ ] Measure `nebula_dust.gdshader`'s per-fragment depth-texture sample across 500 large soft billboards before adding more particle work.