feat(arena): draw the play volume as one non-overlapping surface shell

Every visible surface of the enclosure shared one alpha-blended material and
was drawn as several overlapping layers: floor, ceiling and four walls as
solid BoxMeshes, plus corner curves and fillets generated on top of the box
faces they eased into. Alpha-blended surfaces don't write depth and sort per
object, so the perimeter composited that tint twice and the corners three
times, giving hard-edged trapezoidal patches that re-sorted as the camera
moved. The floor box also overhung the walls by 1 m and showed through them.

Replace all of it with a single generated mesh covering the inner surface
exactly once, every piece cut to meet its neighbours edge-on and only
inward-facing triangles emitted. It carries two surfaces: an opaque hull
(deck, base fillets, goal bulkheads) and the translucent containment field
(walls, corners, ceiling fillets, ceiling).

The field shader is additive and unshaded rather than alpha-blended: additive
cannot double-darken and composites order-independently, so overlap is
structurally invisible, and being unshaded it no longer picks up per-arena
light and GI gradients across a 28x40 m panel. Fresnel replaces the old
StandardMaterial3D rim, which was a lit effect and the wrong tool. The deck
shader draws plating and field markings procedurally from the boundary's own
constants, so markings cannot drift from the collision geometry.

Per-face MeshInstance3D visibility toggling is gone; the field shader fades
facets the camera has crossed outside of per-pixel from one uniform, which is
what allows a single merged mesh.

Also adds ELEVATED goal mode and the ceiling fillets, and skips the mesh build
entirely under --headless, where training spawns instances that never render.

Collision is untouched: 166 collider shapes in FLOOR mode and 158 in ELEVATED,
verified byte-identical to before, so trained policies in Game/bots/ are
unaffected.
This commit is contained in:
Josh Creek
2026-08-04 13:35:55 +01:00
parent 6f5ce488a9
commit 02df09e40d
6 changed files with 674 additions and 257 deletions
+1 -59
View File
@@ -1,36 +1,7 @@
[gd_scene load_steps=11 format=3]
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://scripts/arena_boundary.gd" id="1_bndry"]
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_floor"]
albedo_color = Color(0.08, 0.09, 0.12, 1)
metallic = 0.3
roughness = 0.6
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_field"]
transparency = 1
albedo_color = Color(0.6, 0.75, 1, 0.035)
roughness = 0.05
rim_enabled = true
rim = 1.0
rim_tint = 0.6
[sub_resource type="BoxMesh" id="BoxMesh_floor"]
material = SubResource("StandardMaterial3D_floor")
size = Vector3(28, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_ceiling"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(28, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_side_wall"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(1, 13, 38)
[sub_resource type="BoxMesh" id="BoxMesh_end_wall"]
material = SubResource("StandardMaterial3D_field")
size = Vector3(26, 13, 1)
[sub_resource type="BoxShape3D" id="BoxShape3D_slab"]
size = Vector3(28, 1, 40)
@@ -47,51 +18,22 @@ script = ExtResource("1_bndry")
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
shape = SubResource("BoxShape3D_slab")
[node name="FloorMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
mesh = SubResource("BoxMesh_floor")
[node name="WallPosXShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
shape = SubResource("BoxShape3D_side_wall")
[node name="WallPosXMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12.5, 5.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_side_wall")
[node name="WallNegXShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
shape = SubResource("BoxShape3D_side_wall")
[node name="WallNegXMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -12.5, 5.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_side_wall")
[node name="WallPosZShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
shape = SubResource("BoxShape3D_end_wall")
[node name="WallPosZMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, 18.5)
cast_shadow = 0
mesh = SubResource("BoxMesh_end_wall")
[node name="WallNegZShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
shape = SubResource("BoxShape3D_end_wall")
[node name="WallNegZMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 5.5, -18.5)
cast_shadow = 0
mesh = SubResource("BoxMesh_end_wall")
[node name="CeilingShape" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
shape = SubResource("BoxShape3D_slab")
[node name="CeilingMesh" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12.5, 0)
cast_shadow = 0
mesh = SubResource("BoxMesh_ceiling")
+450 -198
View File
@@ -13,18 +13,31 @@ const INNER_HEIGHT := 12.0
# goal and the wall for a ball to ramp across before reaching the sensor.
const GOAL_LINE_Z := INNER_HALF_Z
# FLOOR (default) keeps the goal flush with the floor, as above. ELEVATED
# moves the goal to GOAL_CENTER_Y — see the arena_0X_elevated.tscn scenes,
# which bake that Y directly onto their Goal nodes and override this export.
# There's no ramp: ships fly freely (see ship.gd), so nothing physically
# needs to lead up to an elevated goal.
enum GoalMode { FLOOR, ELEVATED }
@export var goal_mode: GoalMode = GoalMode.FLOOR
const GOAL_CENTER_Y := INNER_HEIGHT / 2.0 # ELEVATED mode goal centre
# Curved transitions, so the ball rolls back into play instead of wedging
# into a 90° pocket and ships can carry speed up the walls: quarter-cylinder
# corner curves spanning the four vertical wall-wall edges, and base fillets
# easing the floor into every wall. Everything is generated in _ready from
# these constants, but collision and visuals deliberately differ:
# corner curves spanning the four vertical wall-wall edges, base fillets
# easing the floor into every wall, and ceiling fillets easing every wall
# into the ceiling. Everything is generated in _ready from these constants,
# but collision and visuals deliberately differ:
# - collision is rings of thick flat boxes tangent to the true arc — a
# concave curve can't be one convex collider, primitives give Jolt clean
# stable contact normals (the wall-contact reward reads them), and 1 m of
# thickness is tunnel-proof at ball speeds;
# - visuals are single smooth ArrayMesh surfaces (one per corner plus one
# for all fillets) — proper curved normals, no seams or double-tinted
# overlaps, one draw call each.
# - visuals are one merged shell (see _build_visual_shell) — proper curved
# normals, and every surface drawn exactly once.
# The base and ceiling fillets are the same skirting mirrored vertically, so
# every function that builds one takes a `rise` of +1 (base, climbing away
# from the floor) or -1 (ceiling, dropping away from it) and serves both.
# The corner curves reach at most the chord plane
# |x| + |z| = INNER_HALF_X + INNER_HALF_Z - CORNER_RADIUS.
const CORNER_RADIUS := 4.0
@@ -32,6 +45,17 @@ const BASE_RADIUS := 2.0
# The end-wall fillets stop short of the goal mouth so floor-level shots
# roll flat into the goal sensor (3.5 m wide) instead of ramping over it.
const GOAL_MOUTH_HALF_WIDTH := 2.5
# The aperture the visual shell leaves for the goal itself, and the opaque
# bulkhead surrounding it. Mirrors objects/goal.tscn's 3.5 x 1.5 sensor with a
# little clearance, so no sliver of panel shows through the goal frame. The
# surround has to be opaque hull: backed by the translucent field panel
# instead, the goal's recess and net showed straight through the wall beside
# the mouth and read as a second, duplicated net.
const GOAL_APERTURE_HALF_WIDTH := 1.85
const GOAL_APERTURE_HEIGHT := 1.65
# ELEVATED only — in FLOOR mode the surround spans the deck to the fillet
# tangent, so its height is BASE_RADIUS and needs no constant.
const GOAL_SURROUND_HALF_HEIGHT := 1.6
# Flat collider segments per quarter arc. Max sag from the true curve is
# R * (1 - cos(45° / N)): under 4 cm for both radii, invisible to the ball.
const CORNER_SEGMENTS := 6
@@ -42,27 +66,52 @@ const WRAP_SEGMENTS := 3
# ball can sit at most ~4 cm proud of the drawn surface, which never reads).
const CORNER_VISUAL_ARCS := 16
const FILLET_VISUAL_ARCS := 8
# Path steps for the torus patches carrying a fillet around a corner curve.
const WRAP_VISUAL_ARCS := 8
# Match the boxes in arena_boundary.tscn: 1 m thick surfaces, walls spanning
# y -1..12 (flush with the floor slab's bottom and the ceiling slab's top).
const SURFACE_THICKNESS := 1.0
const WALL_HEIGHT := INNER_HEIGHT + 1.0
const WALL_CENTRE_Y := INNER_HEIGHT / 2.0 - 0.5
@onready var _wall_pos_x: MeshInstance3D = $WallPosXMesh
@onready var _wall_neg_x: MeshInstance3D = $WallNegXMesh
@onready var _wall_pos_z: MeshInstance3D = $WallPosZMesh
@onready var _wall_neg_z: MeshInstance3D = $WallNegZMesh
@onready var _ceiling: MeshInstance3D = $CeilingMesh
# Where the flat deck/ceiling stops and the fillets take over, and where the
# fillets hand over to the wall panels. Every piece of the shell is cut to
# these so no two surfaces overlap.
const FLAT_HALF_X := INNER_HALF_X - BASE_RADIUS
const FLAT_HALF_Z := INNER_HALF_Z - BASE_RADIUS
const CORNER_CENTRE_X := INNER_HALF_X - CORNER_RADIUS
const CORNER_CENTRE_Z := INNER_HALF_Z - CORNER_RADIUS
const WRAP_RADIUS := CORNER_RADIUS - BASE_RADIUS
# Corner curve visuals, following the same hide-when-the-camera-is-outside
# rule as the walls: {mesh, point (on the 45° tangent plane), outward}.
var _corner_visuals: Array[Dictionary] = []
const BASE_FILLET := 1.0
const CEILING_FILLET := -1.0
const FIELD_SHADER_PATH := "res://shaders/energy_field.gdshader"
const DECK_SHADER_PATH := "res://shaders/arena_deck.gdshader"
@export var field_tint := Color(0.45, 0.65, 1.0)
@export var field_intensity := 0.09
@export var team0_tint := Color(0.15, 0.45, 1.0)
@export var team1_tint := Color(1.0, 0.35, 0.25)
# The single merged surface shell and the material whose camera-side fade
# _process() drives. Both stay null in headless runs, which never render.
var _shell: MeshInstance3D
var _field_material: ShaderMaterial
func _ready() -> void:
add_to_group("arena_boundary")
_build_corner_curves()
_build_base_fillets()
_build_corner_colliders()
_build_fillet_colliders(BASE_FILLET)
_build_fillet_colliders(CEILING_FILLET)
# Visuals are pure decoration and training spawns many headless instances
# that never render one, so skip the mesh build there entirely. Collision
# is unaffected either way.
if DisplayServer.get_name() == "headless":
set_process(false)
return
_build_visual_shell()
# Wall+ceiling-only proximity force field ("artificial gravity" grav-plating,
@@ -82,8 +131,8 @@ func get_surface_pull(
pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength
pull += Vector3(-1, 0, 0) * _falloff(INNER_HALF_X + p.x, wall_range) * wall_strength
# End walls are gated off inside the goal mouth — there is no physical
# wall there (see GOAL_MOUTH_HALF_WIDTH / _build_base_fillets), so a shot
# heading straight for the net doesn't feel a phantom sideways tug.
# wall there (see GOAL_MOUTH_HALF_WIDTH / _fillet_runs), so a shot heading
# straight for the net doesn't feel a phantom sideways tug.
if abs(p.x) >= GOAL_MOUTH_HALF_WIDTH:
pull += Vector3(0, 0, 1) * _falloff(INNER_HALF_Z - p.z, wall_range) * wall_strength
pull += Vector3(0, 0, -1) * _falloff(INNER_HALF_Z + p.z, wall_range) * wall_strength
@@ -97,40 +146,103 @@ func _falloff(dist: float, field_range: float) -> float:
func _process(_delta: float) -> void:
# The translucent field material tints everything behind it, so any face
# the camera has crossed to the outside of is hidden entirely — looking
# into the arena from outside stays clear, while faces seen from inside
# keep their tint. Collision is untouched; only the meshes toggle.
# The field shader fades out any facet the camera has crossed to the
# outside of, so looking into the arena from outside stays clear. It does
# that per-pixel from this one uniform, which is what lets the whole
# enclosure be a single mesh instead of per-face MeshInstance3Ds toggled
# individually. Collision is untouched.
if _field_material == null:
return
var camera := get_viewport().get_camera_3d()
if camera == null:
return # headless (RL/CI) has no camera
var p := to_local(camera.global_position)
_wall_pos_x.visible = p.x < INNER_HALF_X
_wall_neg_x.visible = p.x > -INNER_HALF_X
_wall_pos_z.visible = p.z < INNER_HALF_Z
_wall_neg_z.visible = p.z > -INNER_HALF_Z
_ceiling.visible = p.y < INNER_HEIGHT
for visual in _corner_visuals:
var mesh: MeshInstance3D = visual["mesh"]
var point: Vector3 = visual["point"]
var outward: Vector3 = visual["outward"]
mesh.visible = (p - point).dot(outward) < 0.0
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
# Vertical quarter-cylinder curves across the four wall-wall corners, faced
# with the walls' translucent field material.
func _build_corner_curves() -> void:
var field_material: Material = (_wall_pos_x.mesh as BoxMesh).material
# --- shared layout -----------------------------------------------------------
# Consumed by both the collider rings and the visual shell, so the two can
# never end up describing different geometry.
func _corner_centre(sx: float, sz: float) -> Vector3:
return Vector3(sx * CORNER_CENTRE_X, 0.0, sz * CORNER_CENTRE_Z)
# The surface a fillet eases out of: the floor for the base run, the ceiling
# for the mirrored one.
func _fillet_base_y(rise: float) -> float:
return 0.0 if rise > 0.0 else INNER_HEIGHT
# Profile direction at `angle`, sweeping from facing the surface the fillet
# eases out of (0) round to facing the wall (90°), pointing from the arc axis
# into the fillet material.
func _fillet_dir(wall_out: Vector3, angle: float, rise: float) -> Vector3:
return wall_out * sin(angle) - Vector3.UP * (rise * cos(angle))
# Each run: horizontal unit vector toward its wall, unit direction along the
# wall, centre of its arc axis, run length, and which ends (in +run_dir /
# -run_dir order) are exposed and need a visual cap rather than meeting a
# corner wrap.
#
# In FLOOR mode the base run along each end wall stops short of the goal mouth
# (flat cutoff, capped) so floor-level shots roll flat into the goal sensor
# instead of ramping over it — the goal sits flush with the wall there (see
# GOAL_LINE_Z). Nothing else splits: an elevated goal isn't at floor level, and
# the ceiling has nothing goal-related to keep clear, so both run full width.
func _fillet_runs(rise: float) -> Array[Dictionary]:
var centre_y := _fillet_base_y(rise) + rise * BASE_RADIUS
var split := rise > 0.0 and goal_mode == GoalMode.FLOOR
var side_run := 2.0 * CORNER_CENTRE_Z
var end_run_full := 2.0 * CORNER_CENTRE_X
var end_run_split := CORNER_CENTRE_X - GOAL_MOUTH_HALF_WIDTH
var end_centre_x := GOAL_MOUTH_HALF_WIDTH + end_run_split / 2.0
var runs: Array[Dictionary] = []
for side in [-1.0, 1.0]:
runs.append({
"wall_out": Vector3(side, 0, 0),
"run_dir": Vector3(0, 0, 1),
"centre": Vector3(side * FLAT_HALF_X, centre_y, 0),
"length": side_run,
"caps": [false, false],
})
if not split:
runs.append({
"wall_out": Vector3(0, 0, side),
"run_dir": Vector3(1, 0, 0),
"centre": Vector3(0, centre_y, side * FLAT_HALF_Z),
"length": end_run_full,
"caps": [false, false],
})
continue
for goal_side in [-1.0, 1.0]:
runs.append({
"wall_out": Vector3(0, 0, side),
"run_dir": Vector3(1, 0, 0),
"centre": Vector3(goal_side * end_centre_x, centre_y, side * FLAT_HALF_Z),
"length": end_run_split,
# The end at the corner wrap is unexposed; the end at the goal
# mouth needs a cap. run_dir is always +X, so the mouth-facing
# end is -run_dir when goal_side is +1.
"caps": [goal_side > 0.0, goal_side < 0.0],
})
return runs
# --- collision ---------------------------------------------------------------
# These build the tangent-box collider rings only. Their geometry is what
# trained policies in Game/bots/ were fitted against, so it must not change.
# Vertical quarter-cylinder curves across the four wall-wall corners.
func _build_corner_colliders() -> void:
var arc_step := (PI / 2.0) / CORNER_SEGMENTS
# Wide enough that adjacent tangent segments overlap instead of gapping.
var face_width := 2.0 * CORNER_RADIUS * tan(arc_step / 2.0) + 0.4
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
var arc_centre := Vector3(
sx * (INNER_HALF_X - CORNER_RADIUS),
0.0,
sz * (INNER_HALF_Z - CORNER_RADIUS)
)
var arc_centre := _corner_centre(sx, sz)
for i in CORNER_SEGMENTS:
# Angle sweeps the quarter arc from facing the ±x wall (0)
# to facing the ±z wall (90°); segments are tangent at
@@ -141,115 +253,37 @@ func _build_corner_curves() -> void:
arc_centre + outward * CORNER_RADIUS + Vector3.UP * WALL_CENTRE_Y,
outward, Vector3.UP, face_width, WALL_HEIGHT
)
_add_corner_visual(sx, sz, arc_centre, field_material)
# One smooth quarter-cylinder surface (floor to ceiling) plus a top cap so
# the curve doesn't read as a hollow tube from above the (hidden) ceiling.
func _add_corner_visual(sx: float, sz: float, arc_centre: Vector3, material: Material) -> void:
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var arc_step := (PI / 2.0) / CORNER_VISUAL_ARCS
var top := Vector3.UP * INNER_HEIGHT
var cap_corner := Vector3(sx * INNER_HALF_X, INNER_HEIGHT, sz * INNER_HALF_Z)
for i in CORNER_VISUAL_ARCS:
var dir_a := Vector3(sx * cos(i * arc_step), 0.0, sz * sin(i * arc_step))
var dir_b := Vector3(sx * cos((i + 1) * arc_step), 0.0, sz * sin((i + 1) * arc_step))
var base_a := arc_centre + dir_a * CORNER_RADIUS
var base_b := arc_centre + dir_b * CORNER_RADIUS
_add_quad(st, base_a, -dir_a, base_b, -dir_b, base_b + top, -dir_b, base_a + top, -dir_a)
_add_cap_tri(st, cap_corner, base_a + top, base_b + top, Vector3.UP)
st.set_material(material)
var mesh_instance := MeshInstance3D.new()
mesh_instance.mesh = st.commit()
mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(mesh_instance)
# Hide when the camera crosses the 45° tangent plane — the deepest point
# of the curve, so it never vanishes while the camera is still in play.
var outward_45 := Vector3(sx, 0.0, sz).normalized()
_corner_visuals.append({
"mesh": mesh_instance,
"point": arc_centre + outward_45 * CORNER_RADIUS,
"outward": outward_45,
})
# Quarter-cylinder fillets easing the floor into each wall, faced with the
# floor material (they read as curved skirting, and like the floor they are
# never hidden — they sit too low to block the view). Torus sections wrap
# the fillet around each corner curve, joining the side- and end-wall runs
# with no exposed end face; the goal-mouth ends stay a simple flat cutoff
# (capped) since the goal now sits flush with the wall there (see
# GOAL_LINE_Z) — there is no floating approach for a ship to hug at speed.
func _build_base_fillets() -> void:
var floor_material: Material = ($FloorMesh.mesh as BoxMesh).material
# Quarter-cylinder fillets easing every wall into the floor (rise +1) or the
# ceiling (rise -1), with torus sections wrapping each corner curve so the
# side- and end-wall runs join with no exposed end face.
func _build_fillet_colliders(rise: float) -> void:
var arc_step := (PI / 2.0) / BASE_SEGMENTS
var face_width := 2.0 * BASE_RADIUS * tan(arc_step / 2.0) + 0.3
# Side-wall fillets run the full span between the corner wraps; end-wall
# fillets run from the corner wraps to the goal mouth.
var side_run := 2.0 * (INNER_HALF_Z - CORNER_RADIUS)
var end_run := (INNER_HALF_X - CORNER_RADIUS) - GOAL_MOUTH_HALF_WIDTH
var end_centre_x := GOAL_MOUTH_HALF_WIDTH + end_run / 2.0
# Each run: horizontal unit vector toward its wall, unit direction along
# the wall, centre of its arc axis, run length, and which ends (in
# +run_dir / -run_dir order) are exposed and need a cap rather than
# meeting a corner wrap.
var runs: Array[Dictionary] = []
for side in [-1.0, 1.0]:
runs.append({
"wall_out": Vector3(side, 0, 0),
"run_dir": Vector3(0, 0, 1),
"centre": Vector3(side * (INNER_HALF_X - BASE_RADIUS), BASE_RADIUS, 0),
"length": side_run,
"caps": [false, false],
})
for goal_side in [-1.0, 1.0]:
runs.append({
"wall_out": Vector3(0, 0, side),
"run_dir": Vector3(1, 0, 0),
"centre": Vector3(goal_side * end_centre_x, BASE_RADIUS, side * (INNER_HALF_Z - BASE_RADIUS)),
"length": end_run,
# The end at the corner wrap is unexposed; the end at the
# goal mouth needs a cap. run_dir is always +X, so the
# mouth-facing end is -run_dir when goal_side is +1.
"caps": [goal_side > 0.0, goal_side < 0.0],
})
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
for run in runs:
for run in _fillet_runs(rise):
var wall_out: Vector3 = run["wall_out"]
var run_dir: Vector3 = run["run_dir"]
var centre: Vector3 = run["centre"]
var length: float = run["length"]
var caps: Array = run["caps"]
# Profile angle sweeps the quarter arc from facing the floor (0) to
# facing the wall (90°); the direction points from the arc axis into
# the fillet material.
for i in BASE_SEGMENTS:
var outward := _fillet_dir(wall_out, (i + 0.5) * arc_step)
var outward := _fillet_dir(wall_out, (i + 0.5) * arc_step, rise)
_add_curve_collider(centre + outward * BASE_RADIUS, outward, run_dir, face_width, length)
_add_fillet_visual(st, wall_out, run_dir, centre, length, caps[0], caps[1])
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
_add_fillet_corner_wrap(st, sx, sz)
st.set_material(floor_material)
var mesh_instance := MeshInstance3D.new()
mesh_instance.mesh = st.commit()
mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(mesh_instance)
_add_wrap_colliders(sx, sz, rise)
# Torus section carrying the fillet around a corner curve's base: the fillet
# profile swept along the corner arc, meeting the straight runs flush at both
# ends. Sweep position: with u(phi) the horizontal radial direction from the
# corner arc's centre, the surface is
# P(phi, theta) = centre + u * (CORNER_RADIUS - BASE_RADIUS
# + BASE_RADIUS * sin(theta)) + UP * BASE_RADIUS * (1 - cos(theta))
# whose outward (into-material) normal is u * sin(theta) - UP * cos(theta).
func _add_fillet_corner_wrap(st: SurfaceTool, sx: float, sz: float) -> void:
var origin := Vector3(sx * (INNER_HALF_X - CORNER_RADIUS), 0.0, sz * (INNER_HALF_Z - CORNER_RADIUS))
var axis_radius := CORNER_RADIUS - BASE_RADIUS
# Torus section carrying a fillet around a corner curve. With u(phi) the
# horizontal radial direction from the corner arc's centre, the surface is
# P(phi, theta) = origin + u * (CORNER_RADIUS - BASE_RADIUS
# + BASE_RADIUS * sin(theta))
# + UP * rise * BASE_RADIUS * (1 - cos(theta))
# whose outward (into-material) normal is
# u * sin(theta) - UP * rise * cos(theta).
# Note `rise` enters the position and the normal with opposite signs.
func _add_wrap_colliders(sx: float, sz: float, rise: float) -> void:
var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise)
var path_step := (PI / 2.0) / WRAP_SEGMENTS
var profile_step := (PI / 2.0) / BASE_SEGMENTS
var face_width := 2.0 * BASE_RADIUS * tan(profile_step / 2.0) + 0.3
@@ -259,70 +293,12 @@ func _add_fillet_corner_wrap(st: SurfaceTool, sx: float, sz: float) -> void:
var along := Vector3(-sx * sin(phi), 0.0, sz * cos(phi))
for j in BASE_SEGMENTS:
var theta := (j + 0.5) * profile_step
var ring_radius := axis_radius + BASE_RADIUS * sin(theta)
var ring_radius := WRAP_RADIUS + BASE_RADIUS * sin(theta)
var face_centre := origin + u * ring_radius \
+ Vector3.UP * (BASE_RADIUS * (1.0 - cos(theta)))
var outward := u * sin(theta) - Vector3.UP * cos(theta)
+ Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta)))
var outward := u * sin(theta) - Vector3.UP * (rise * cos(theta))
var run_length := ring_radius * 2.0 * tan(path_step / 2.0) + 0.3
_add_curve_collider(face_centre, outward, along, face_width, run_length)
# Smooth visual patch over the same torus.
var v_path := 8
var v_step := (PI / 2.0) / v_path
var p_step := (PI / 2.0) / FILLET_VISUAL_ARCS
for i in v_path:
for j in FILLET_VISUAL_ARCS:
var points: Array[Vector3] = []
var normals: Array[Vector3] = []
for corner in [[i, j], [i + 1, j], [i + 1, j + 1], [i, j + 1]]:
var u_c := Vector3(sx * cos(corner[0] * v_step), 0.0, sz * sin(corner[0] * v_step))
var theta_c: float = corner[1] * p_step
points.append(origin + u_c * (axis_radius + BASE_RADIUS * sin(theta_c)) \
+ Vector3.UP * (BASE_RADIUS * (1.0 - cos(theta_c))))
normals.append(-(u_c * sin(theta_c) - Vector3.UP * cos(theta_c)))
_add_quad(
st, points[0], normals[0], points[1], normals[1],
points[2], normals[2], points[3], normals[3]
)
# One smooth fillet strip, optionally capped at either end (see caller: an
# end is capped when it stops at the goal mouth rather than meeting a
# corner wrap).
func _add_fillet_visual(
st: SurfaceTool, wall_out: Vector3, run_dir: Vector3, centre: Vector3, length: float,
cap_start: bool, cap_end: bool
) -> void:
var arc_step := (PI / 2.0) / FILLET_VISUAL_ARCS
var end_a := centre - run_dir * (length / 2.0)
var end_b := centre + run_dir * (length / 2.0)
for i in FILLET_VISUAL_ARCS:
var dir_a := _fillet_dir(wall_out, i * arc_step)
var dir_b := _fillet_dir(wall_out, (i + 1) * arc_step)
_add_quad(
st,
end_a + dir_a * BASE_RADIUS, -dir_a,
end_b + dir_a * BASE_RADIUS, -dir_a,
end_b + dir_b * BASE_RADIUS, -dir_b,
end_a + dir_b * BASE_RADIUS, -dir_b
)
var caps: Array[Array] = []
if cap_start:
caps.append([end_a, -run_dir])
if cap_end:
caps.append([end_b, run_dir])
for cap in caps:
var end_point: Vector3 = cap[0]
var cap_normal: Vector3 = cap[1]
# Fan from the wall-floor corner of the cross-section to the arc.
var cap_corner := end_point + (wall_out + Vector3.DOWN) * BASE_RADIUS
for i in FILLET_VISUAL_ARCS:
var p0 := end_point + _fillet_dir(wall_out, i * arc_step) * BASE_RADIUS
var p1 := end_point + _fillet_dir(wall_out, (i + 1) * arc_step) * BASE_RADIUS
_add_cap_tri(st, cap_corner, p0, p1, cap_normal)
func _fillet_dir(wall_out: Vector3, angle: float) -> Vector3:
return wall_out * sin(angle) + Vector3.DOWN * cos(angle)
# One flat tangent collider segment of a curved surface: a box whose inner
@@ -342,6 +318,282 @@ func _add_curve_collider(
add_child(collision)
# --- visual shell ------------------------------------------------------------
# One mesh covering the inner surface of the play volume exactly once. Every
# piece is cut to meet its neighbours edge-on, so no two translucent surfaces
# overlap — overlapping alpha was what made the enclosure read as patches of
# differing brightness. Only inward-facing triangles are emitted; the outer
# faces and the 1 m overhang the old BoxMeshes carried simply don't exist.
#
# Two surfaces share the mesh: an opaque hull (deck, base fillets and the goal
# surrounds) and the translucent containment field (walls, corners, ceiling
# fillets, ceiling).
func _build_visual_shell() -> void:
var mesh := ArrayMesh.new()
var deck := SurfaceTool.new()
deck.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_flat_panel(deck, 0.0, Vector3.UP)
_add_goal_aprons(deck)
_add_goal_surrounds(deck)
_add_fillet_surface(deck, BASE_FILLET)
deck.commit(mesh)
mesh.surface_set_material(0, _make_deck_material())
_field_material = _make_field_material()
var field := SurfaceTool.new()
field.begin(Mesh.PRIMITIVE_TRIANGLES)
_add_wall_panels(field)
_add_corner_panels(field)
_add_fillet_surface(field, CEILING_FILLET)
_add_flat_panel(field, INNER_HEIGHT, Vector3.DOWN)
field.commit(mesh)
mesh.surface_set_material(1, _field_material)
_shell = MeshInstance3D.new()
_shell.name = "SurfaceShell"
_shell.mesh = mesh
# Ships and the ball still cast onto the deck; the deck shadowing itself
# would only produce acne.
_shell.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_shell)
func _make_deck_material() -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = load(DECK_SHADER_PATH)
# The markings are drawn from these, so they track the collision geometry.
mat.set_shader_parameter("half_x", INNER_HALF_X)
mat.set_shader_parameter("half_z", INNER_HALF_Z)
mat.set_shader_parameter("goal_line_z", GOAL_LINE_Z)
mat.set_shader_parameter("base_radius", BASE_RADIUS)
mat.set_shader_parameter("team0_color", team0_tint)
mat.set_shader_parameter("team1_color", team1_tint)
mat.set_shader_parameter("seam_color", field_tint)
return mat
func _make_field_material() -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = load(FIELD_SHADER_PATH)
mat.set_shader_parameter("field_color", field_tint)
mat.set_shader_parameter("field_intensity", field_intensity)
return mat
# The flat deck (or ceiling): a rounded rectangle whose straight edges stop on
# the fillet tangents and whose corners follow the corner wraps' inner arc, so
# it meets the fillets exactly once. Decomposed into a centre span, two end
# spans narrowed to clear the corner arcs, and four quarter-discs — a partition
# with no overlaps.
func _add_flat_panel(st: SurfaceTool, y: float, face: Vector3) -> void:
var origin := Vector3(0, y, 0)
var right := Vector3(1, 0, 0)
var forward := Vector3(0, 0, 1)
_add_plane_quad(st, origin, right, forward, face,
-FLAT_HALF_X, FLAT_HALF_X, -CORNER_CENTRE_Z, CORNER_CENTRE_Z)
for sz in [-1.0, 1.0]:
var z0: float = sz * CORNER_CENTRE_Z
var z1: float = sz * FLAT_HALF_Z
_add_plane_quad(st, origin, right, forward, face,
-CORNER_CENTRE_X, CORNER_CENTRE_X, minf(z0, z1), maxf(z0, z1))
var step := (PI / 2.0) / FILLET_VISUAL_ARCS
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
var centre := _corner_centre(sx, sz) + Vector3.UP * y
for i in FILLET_VISUAL_ARCS:
var d0 := Vector3(sx * cos(i * step), 0.0, sz * sin(i * step))
var d1 := Vector3(sx * cos((i + 1) * step), 0.0, sz * sin((i + 1) * step))
_add_cap_tri(st, centre, centre + d0 * WRAP_RADIUS, centre + d1 * WRAP_RADIUS, face)
# In FLOOR mode the end-wall fillet stops short of the goal mouth, so the deck
# has to run flat all the way to the end wall across that gap — otherwise there
# would be a hole in front of each goal.
func _add_goal_aprons(st: SurfaceTool) -> void:
if goal_mode != GoalMode.FLOOR:
return
for sz in [-1.0, 1.0]:
var z0: float = sz * FLAT_HALF_Z
var z1: float = sz * INNER_HALF_Z
_add_plane_quad(st, Vector3.ZERO, Vector3(1, 0, 0), Vector3(0, 0, 1), Vector3.UP,
-GOAL_MOUTH_HALF_WIDTH, GOAL_MOUTH_HALF_WIDTH, minf(z0, z1), maxf(z0, z1))
# Opaque bulkhead around each goal mouth, with the aperture cut out of it. This
# is the solid hull the goal recess is set into: left as translucent field
# panel, the recess and net behind it showed through the wall and the net read
# as duplicated either side of the frame.
func _add_goal_surrounds(st: SurfaceTool) -> void:
var bounds := _goal_surround_bounds()
for side in [-1.0, 1.0]:
_add_aperture_panel(st,
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
Vector3(0, 0, -side), GOAL_MOUTH_HALF_WIDTH, bounds.x, bounds.y,
GOAL_APERTURE_HALF_WIDTH, bounds.z, bounds.w)
# (surround bottom, surround top, aperture bottom, aperture top) on the end
# wall. In FLOOR mode the surround spans deck to fillet tangent with the goal
# sitting on the deck; in ELEVATED it is a band centred on the raised goal.
func _goal_surround_bounds() -> Vector4:
if goal_mode == GoalMode.FLOOR:
return Vector4(0.0, BASE_RADIUS, 0.0, GOAL_APERTURE_HEIGHT)
var half := GOAL_APERTURE_HEIGHT / 2.0
return Vector4(
GOAL_CENTER_Y - GOAL_SURROUND_HALF_HEIGHT,
GOAL_CENTER_Y + GOAL_SURROUND_HALF_HEIGHT,
GOAL_CENTER_Y - half,
GOAL_CENTER_Y + half)
func _add_fillet_surface(st: SurfaceTool, rise: float) -> void:
for run in _fillet_runs(rise):
var caps: Array = run["caps"]
_add_fillet_visual(st, run["wall_out"], run["run_dir"], run["centre"], run["length"],
caps[0], caps[1], rise)
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
_add_wrap_visual(st, sx, sz, rise)
# Wall panels, spanning between the fillets vertically and between the corner
# curves horizontally. Each end wall is cut around the goal surround, which is
# opaque hull carrying the aperture itself (see _add_goal_surrounds). In FLOOR
# mode that surround sits below the panels entirely, so only the ELEVATED one
# punches a hole through them.
func _add_wall_panels(st: SurfaceTool) -> void:
var y0 := BASE_RADIUS
var y1 := INNER_HEIGHT - BASE_RADIUS
var elevated := goal_mode == GoalMode.ELEVATED
var bounds := _goal_surround_bounds()
for side in [-1.0, 1.0]:
_add_aperture_panel(st,
Vector3(side * INNER_HALF_X, 0, 0), Vector3(0, 0, side), Vector3.UP,
Vector3(-side, 0, 0), CORNER_CENTRE_Z, y0, y1, 0.0, 0.0, 0.0)
_add_aperture_panel(st,
Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP,
Vector3(0, 0, -side), CORNER_CENTRE_X, y0, y1,
GOAL_MOUTH_HALF_WIDTH if elevated else 0.0, bounds.x, bounds.y)
# The corner quarter-cylinders, trimmed to the wall panels' height range so
# they abut the fillet wraps instead of running through them.
func _add_corner_panels(st: SurfaceTool) -> void:
var y0 := BASE_RADIUS
var y1 := INNER_HEIGHT - BASE_RADIUS
var arc_step := (PI / 2.0) / CORNER_VISUAL_ARCS
for sx in [-1.0, 1.0]:
for sz in [-1.0, 1.0]:
var arc_centre := _corner_centre(sx, sz)
for i in CORNER_VISUAL_ARCS:
var d0 := Vector3(sx * cos(i * arc_step), 0.0, sz * sin(i * arc_step))
var d1 := Vector3(sx * cos((i + 1) * arc_step), 0.0, sz * sin((i + 1) * arc_step))
var b0 := arc_centre + d0 * CORNER_RADIUS
var b1 := arc_centre + d1 * CORNER_RADIUS
_add_quad(st,
b0 + Vector3.UP * y0, -d0, b1 + Vector3.UP * y0, -d1,
b1 + Vector3.UP * y1, -d1, b0 + Vector3.UP * y1, -d0)
# One smooth fillet strip, optionally capped at either end (an end is capped
# when it stops at the goal mouth rather than meeting a corner wrap).
func _add_fillet_visual(
st: SurfaceTool, wall_out: Vector3, run_dir: Vector3, centre: Vector3, length: float,
cap_start: bool, cap_end: bool, rise: float
) -> void:
var arc_step := (PI / 2.0) / FILLET_VISUAL_ARCS
var end_a := centre - run_dir * (length / 2.0)
var end_b := centre + run_dir * (length / 2.0)
for i in FILLET_VISUAL_ARCS:
var dir_a := _fillet_dir(wall_out, i * arc_step, rise)
var dir_b := _fillet_dir(wall_out, (i + 1) * arc_step, rise)
_add_quad(
st,
end_a + dir_a * BASE_RADIUS, -dir_a,
end_b + dir_a * BASE_RADIUS, -dir_a,
end_b + dir_b * BASE_RADIUS, -dir_b,
end_a + dir_b * BASE_RADIUS, -dir_b
)
var caps: Array[Array] = []
if cap_start:
caps.append([end_a, -run_dir])
if cap_end:
caps.append([end_b, run_dir])
for cap in caps:
var end_point: Vector3 = cap[0]
var cap_normal: Vector3 = cap[1]
# Fan from the wall-floor corner of the cross-section round to the arc.
var cap_corner := end_point + (wall_out + _fillet_dir(wall_out, 0.0, rise)) * BASE_RADIUS
for i in FILLET_VISUAL_ARCS:
var p0 := end_point + _fillet_dir(wall_out, i * arc_step, rise) * BASE_RADIUS
var p1 := end_point + _fillet_dir(wall_out, (i + 1) * arc_step, rise) * BASE_RADIUS
_add_cap_tri(st, cap_corner, p0, p1, cap_normal)
# Smooth torus patch over the same sweep _add_wrap_colliders describes.
func _add_wrap_visual(st: SurfaceTool, sx: float, sz: float, rise: float) -> void:
var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise)
var path_step := (PI / 2.0) / WRAP_VISUAL_ARCS
var profile_step := (PI / 2.0) / FILLET_VISUAL_ARCS
for i in WRAP_VISUAL_ARCS:
for j in FILLET_VISUAL_ARCS:
var points: Array[Vector3] = []
var normals: Array[Vector3] = []
for corner in [[i, j], [i + 1, j], [i + 1, j + 1], [i, j + 1]]:
var phi: float = corner[0] * path_step
var theta: float = corner[1] * profile_step
var u := Vector3(sx * cos(phi), 0.0, sz * sin(phi))
points.append(origin + u * (WRAP_RADIUS + BASE_RADIUS * sin(theta))
+ Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta))))
normals.append(-(u * sin(theta) - Vector3.UP * (rise * cos(theta))))
_add_quad(
st, points[0], normals[0], points[1], normals[1],
points[2], normals[2], points[3], normals[3]
)
# --- meshing primitives ------------------------------------------------------
# A flat rectangle in the plane through `plane_origin` spanned by `right`/`up`,
# with an optional rectangular aperture cut out of it (hole_half_width <= 0 for
# none). Decomposed into the up-to-four quads surrounding the hole so the panel
# stays a single non-overlapping layer.
func _add_aperture_panel(
st: SurfaceTool, plane_origin: Vector3, right: Vector3, up: Vector3, normal: Vector3,
half_width: float, v0: float, v1: float,
hole_half_width: float, hole_v0: float, hole_v1: float
) -> void:
if hole_half_width <= 0.0:
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, v0, v1)
return
var lo: float = clampf(hole_v0, v0, v1)
var hi: float = clampf(hole_v1, v0, v1)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, v0, lo)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, hi, v1)
_add_plane_quad(st, plane_origin, right, up, normal, -half_width, -hole_half_width, lo, hi)
_add_plane_quad(st, plane_origin, right, up, normal, hole_half_width, half_width, lo, hi)
# One quad in the plane through `plane_origin` spanned by `right`/`up`, over
# the given parameter ranges. Degenerate spans are skipped so callers can pass
# empty slices (an aperture flush with a panel edge, say) without guarding.
func _add_plane_quad(
st: SurfaceTool, plane_origin: Vector3, right: Vector3, up: Vector3,
normal: Vector3, u0: float, u1: float, v0: float, v1: float
) -> void:
if u1 - u0 <= 0.0001 or v1 - v0 <= 0.0001:
return
_add_quad(st,
plane_origin + right * u0 + up * v0, normal,
plane_origin + right * u1 + up * v0, normal,
plane_origin + right * u1 + up * v1, normal,
plane_origin + right * u0 + up * v1, normal)
# 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).
func _add_quad(
+138
View File
@@ -0,0 +1,138 @@
// Opaque hull for the arena: the deck, the fillets easing it into the walls,
// and the bulkheads surrounding each goal mouth — the lit half of
// ArenaBoundary's visual shell.
//
// Everything is drawn procedurally from the vertex's position in the
// boundary's local space, so field markings are resolution-independent and
// need no decals, UVs or texture authoring. All dimensions arrive as uniforms
// set from ArenaBoundary's constants (see _make_deck_material), so the
// markings cannot drift away from the collision geometry they describe.
shader_type spatial;
render_mode cull_back, diffuse_burley, specular_schlick_ggx;
uniform vec3 deck_color : source_color = vec3(0.085, 0.095, 0.122);
// Kept close to deck_color on purpose: per-plate variation is meant to break
// up a 24x36 m plane, not to reintroduce visible tiling.
uniform vec3 panel_color : source_color = vec3(0.104, 0.116, 0.146);
// Stand-in for bounce light. The arena is lit by one dim directional plus low
// ambient, so hull facing away from it — the goal bulkheads, the fillet caps
// either side of each mouth — otherwise crushed to pure black and the whole
// end zone read as a void with a goal floating in it.
uniform float hull_fill : hint_range(0.0, 2.0) = 0.55;
uniform float panel_variation : hint_range(0.0, 1.0) = 0.4;
uniform vec3 line_color : source_color = vec3(0.62, 0.78, 1.0);
uniform vec3 team0_color : source_color = vec3(0.15, 0.45, 1.0);
uniform vec3 team1_color : source_color = vec3(1.0, 0.35, 0.25);
uniform vec3 seam_color : source_color = vec3(0.35, 0.75, 1.0);
// Play-volume dimensions, from ArenaBoundary's constants.
uniform float half_x = 12.0;
uniform float half_z = 18.0;
uniform float goal_line_z = 18.0;
uniform float base_radius = 2.0;
uniform float centre_circle_radius = 4.0;
uniform float goal_area_depth = 6.0;
uniform float goal_area_half_width = 6.0;
uniform float line_width = 0.14;
// Each arena's Environment runs glow at hdr_threshold ~1.0 with hdr_scale 2.0,
// so emission much above 1 blooms into a white smear. These stay under it.
uniform float line_emission : hint_range(0.0, 8.0) = 0.65;
uniform float seam_emission : hint_range(0.0, 8.0) = 0.8;
uniform float seam_width = 0.22;
uniform float end_zone_strength : hint_range(0.0, 1.0) = 0.12;
uniform float panel_size = 4.0;
uniform float panel_line_width : hint_range(0.0, 0.5) = 0.05;
// Deliberately not a high metallic value: this arena is lit by one dim
// directional plus low ambient, and a near-metal deck has almost no diffuse
// response, which crushed the plating to black.
uniform float deck_metallic : hint_range(0.0, 1.0) = 0.3;
uniform float deck_roughness : hint_range(0.0, 1.0) = 0.34;
varying vec3 v_local;
varying vec3 v_local_normal;
void vertex() {
v_local = VERTEX;
v_local_normal = NORMAL;
}
float hash21(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}
// 1 inside a band of width `w` centred on the zero of `d`, screen-space
// antialiased so lines stay crisp at grazing angles and don't shimmer.
float band(float d, float w) {
float aa = fwidth(d) + 0.001;
return 1.0 - smoothstep(w * 0.5, w * 0.5 + aa, abs(d));
}
// Antialiased outline of the axis-aligned box |p| <= half_size.
float rect_outline(vec2 p, vec2 half_size, float w) {
vec2 d = abs(p) - half_size;
return band(max(d.x, d.y), w);
}
// Plate seam mask for one projection plane.
float plate_seam(vec2 p) {
vec2 d = abs(fract(p / panel_size) - 0.5) * panel_size;
float m = min(d.x, d.y);
return 1.0 - smoothstep(panel_line_width, panel_line_width + fwidth(m) + 0.01, m);
}
void fragment() {
vec2 xz = v_local.xz;
vec3 n = normalize(v_local_normal);
float up_facing = clamp(n.y, 0.0, 1.0);
// --- hull plating ----------------------------------------------------
// Projected onto whichever plane each facet most faces, blended by
// |normal|, so plating doesn't smear up the fillets or stripe the vertical
// goal bulkheads the way a flat XZ projection did.
vec3 w = abs(n);
w /= max(w.x + w.y + w.z, 0.001);
float seams = w.x * plate_seam(v_local.zy)
+ w.y * plate_seam(v_local.xz)
+ w.z * plate_seam(v_local.xy);
vec3 albedo = mix(deck_color, panel_color, hash21(floor(xz / panel_size)) * panel_variation);
albedo = mix(albedo, deck_color * 0.55, seams);
float rough = deck_roughness + seams * 0.25;
// --- team-tinted end zones -------------------------------------------
// Team 0 defends +z (see the arenas' GoalTeam0 transforms), team 1 -z.
float t0 = smoothstep(0.0, half_z, xz.y);
float t1 = smoothstep(0.0, half_z, -xz.y);
vec3 zone = team0_color * t0 + team1_color * t1;
// Markings belong on the flat deck only: fade them out as the fillet lifts
// away from the floor, and drop them entirely on anything not facing up so
// they never wrap onto a goal bulkhead.
float flat_deck = (1.0 - smoothstep(0.0, base_radius * 0.4, v_local.y)) * up_facing;
albedo = mix(albedo, albedo + zone * 0.18, end_zone_strength * flat_deck);
// --- field markings ---------------------------------------------------
float marks = band(length(xz) - centre_circle_radius, line_width);
marks = max(marks, band(xz.y, line_width));
for (int i = 0; i < 2; i++) {
float s = i == 0 ? 1.0 : -1.0;
vec2 c = vec2(0.0, s * (goal_line_z - goal_area_depth * 0.5));
marks = max(marks, rect_outline(
xz - c, vec2(goal_area_half_width, goal_area_depth * 0.5), line_width));
}
marks *= flat_deck;
// --- deck/field handover ---------------------------------------------
// A band, not a step: it has to light the top edge of the fillets and the
// matching edge of the goal bulkheads without setting the whole of an
// elevated bulkhead (which sits far above base_radius) glowing.
float seam = 1.0 - smoothstep(0.0, seam_width, abs(v_local.y - base_radius));
ALBEDO = mix(albedo, line_color, marks);
METALLIC = deck_metallic * (1.0 - marks);
ROUGHNESS = clamp(mix(rough, 0.5, marks), 0.05, 1.0);
EMISSION = albedo * hull_fill
+ line_color * marks * line_emission
+ seam_color * seam * seam_emission
+ zone * 0.015 * flat_deck;
}
+1
View File
@@ -0,0 +1 @@
uid://bogr8ydj6q8rp
+83
View File
@@ -0,0 +1,83 @@
// Containment-field surface for the arena walls, ceiling, corner curves and
// ceiling fillets — the translucent half of ArenaBoundary's visual shell.
//
// Additive and unshaded on purpose. The StandardMaterial3D this replaces used
// alpha blending plus rim lighting, which had two problems:
// - alpha blending darkens whatever is behind it, so wherever two boundary
// surfaces overlapped the tint composited twice and the overlap read as a
// hard-edged patch that re-sorted as the camera moved;
// - rim is a *lit* effect, so each arena's two DirectionalLight3Ds and SDFGI
// drove visible gradients across a single 28x40 m panel.
// Additive cannot double-darken and composites order-independently, so the
// field reads uniformly regardless of how the geometry happens to sort.
shader_type spatial;
render_mode blend_add, depth_draw_never, cull_back, unshaded;
uniform vec3 field_color : source_color = vec3(0.45, 0.65, 1.0);
uniform float field_intensity : hint_range(0.0, 1.0) = 0.09;
// Face-on brightness floor, before fresnel and cell detail.
uniform float base_level : hint_range(0.0, 2.0) = 0.5;
// Grazing-angle glow — the "glass" read the old rim param was reaching for.
// Kept gentle: a steep exponent makes a head-on end wall read as a hard-edged
// dark rectangle against the corner curves flanking it.
uniform float fresnel_power : hint_range(0.5, 8.0) = 2.2;
uniform float fresnel_boost : hint_range(0.0, 8.0) = 2.6;
uniform float cell_size = 1.6;
uniform float cell_width : hint_range(0.0, 0.5) = 0.06;
uniform float cell_strength : hint_range(0.0, 4.0) = 1.1;
// Camera position in ArenaBoundary's local space, pushed each frame by
// arena_boundary.gd. Facets the camera has crossed to the outside of fade out
// rather than popping, so looking into the arena from outside stays clear.
// Per-pixel, which is what lets one merged shell replace the old per-face
// MeshInstance3D visibility toggling.
uniform vec3 camera_local_pos = vec3(0.0, 6.0, 0.0);
uniform float cull_fade_distance : hint_range(0.01, 8.0) = 1.25;
varying vec3 v_local_pos;
varying vec3 v_local_normal;
void vertex() {
v_local_pos = VERTEX;
v_local_normal = NORMAL;
}
// Offset-grid nearest-cell offset, used to build a hex lattice.
vec2 hex_cell(vec2 p) {
vec2 r = vec2(1.0, 1.7320508);
vec2 h = r * 0.5;
vec2 a = mod(p, r) - h;
vec2 b = mod(p - h, r) - h;
return dot(a, a) < dot(b, b) ? a : b;
}
float hex_edge(vec2 p) {
vec2 g = abs(hex_cell(p / cell_size));
float d = max(dot(g, normalize(vec2(1.0, 1.7320508))), g.x);
return smoothstep(0.5 - cell_width, 0.5, d);
}
void fragment() {
vec3 n = normalize(v_local_normal);
// Project the hex lattice onto whichever plane the facet most faces,
// blended by |normal| so the curved corner and fillet surfaces cross
// between projections without a seam.
vec3 w = abs(n);
w /= max(w.x + w.y + w.z, 0.001);
float cells =
w.x * hex_edge(v_local_pos.zy) +
w.y * hex_edge(v_local_pos.xz) +
w.z * hex_edge(v_local_pos.xy);
float facing = clamp(dot(normalize(NORMAL), normalize(VIEW)), 0.0, 1.0);
float fresnel = pow(1.0 - facing, fresnel_power);
float level = base_level + fresnel * fresnel_boost + cells * cell_strength;
ALBEDO = field_color * level * field_intensity;
// Signed distance from the camera to this facet's plane along its inward
// normal: positive while the camera is still inside the enclosure, so the
// panel fades out just before back-face culling would drop it.
float side = dot(camera_local_pos - v_local_pos, n);
ALPHA = smoothstep(0.0, cull_fade_distance, side);
}
+1
View File
@@ -0,0 +1 @@
uid://bqe75ud45htst