diff --git a/Game/scripts/goal.gd b/Game/scripts/goal.gd index 40bb20ec..65312b42 100644 --- a/Game/scripts/goal.gd +++ b/Game/scripts/goal.gd @@ -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: diff --git a/TODO.md b/TODO.md index 1ba511f8..e2241962 100644 --- a/TODO.md +++ b/TODO.md @@ -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.