diff --git a/Game/objects/arena_boundary.tscn b/Game/objects/arena_boundary.tscn index e6cd33c0..ff6f37ed 100644 --- a/Game/objects/arena_boundary.tscn +++ b/Game/objects/arena_boundary.tscn @@ -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") diff --git a/Game/scripts/arena_boundary.gd b/Game/scripts/arena_boundary.gd index a5d1a0b9..32de28ce 100644 --- a/Game/scripts/arena_boundary.gd +++ b/Game/scripts/arena_boundary.gd @@ -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( diff --git a/Game/shaders/arena_deck.gdshader b/Game/shaders/arena_deck.gdshader new file mode 100644 index 00000000..20470907 --- /dev/null +++ b/Game/shaders/arena_deck.gdshader @@ -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; +} diff --git a/Game/shaders/arena_deck.gdshader.uid b/Game/shaders/arena_deck.gdshader.uid new file mode 100644 index 00000000..4928b434 --- /dev/null +++ b/Game/shaders/arena_deck.gdshader.uid @@ -0,0 +1 @@ +uid://bogr8ydj6q8rp diff --git a/Game/shaders/energy_field.gdshader b/Game/shaders/energy_field.gdshader new file mode 100644 index 00000000..0dfb48b2 --- /dev/null +++ b/Game/shaders/energy_field.gdshader @@ -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); +} diff --git a/Game/shaders/energy_field.gdshader.uid b/Game/shaders/energy_field.gdshader.uid new file mode 100644 index 00000000..c978b6d8 --- /dev/null +++ b/Game/shaders/energy_field.gdshader.uid @@ -0,0 +1 @@ +uid://bqe75ud45htst