mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 18:53:42 +00:00
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:
+105
-28
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user