981ebf1910
Remove tools/fstest.html scratch page used to probe browser fullscreen/orientation APIs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EznnY8rH2dXhtono1kwsXg
195 lines
8.0 KiB
GDScript
195 lines
8.0 KiB
GDScript
extends MultiMeshInstance3D
|
|
## A whole level's worth of persistent blood splats rendered in one draw call. Each stain
|
|
## is a flat quad laid on the surface it hit — floor or a nearby wall — picked from a
|
|
## procedurally-baked atlas of splat shapes and jittered per instance (random shape, yaw,
|
|
## size, brightness) so no two read alike.
|
|
##
|
|
## The pool is a fixed ring buffer: new splats overwrite the oldest, so the instance count
|
|
## and fill cost stay bounded no matter how long a fight runs — the property that keeps it
|
|
## cheap on the web/mobile target. The game shell rebuilds this node into LevelRoot on every
|
|
## level load, so the stains die with the level (exactly one level's worth, never leaked
|
|
## into the next). Fire splats through the `Gore` autoload, not this node directly.
|
|
|
|
const PhysicsLayers = preload("res://physics_layers.gd")
|
|
const SHADER_PATH := "res://blood_decals.gdshader"
|
|
const GROUP := &"blood_decals"
|
|
|
|
# Atlas: ATLAS_CELLS distinct splat shapes packed into one row, CELL_PX square each.
|
|
const ATLAS_CELLS := 8
|
|
const CELL_PX := 64
|
|
|
|
var _rng := RandomNumberGenerator.new()
|
|
var _mm: MultiMesh
|
|
var _cap: int = 0
|
|
var _next: int = 0 # ring-buffer write cursor
|
|
var _filled: int = 0 # how many ring-buffer slots have ever been stamped, capped at _cap
|
|
|
|
|
|
func _ready() -> void:
|
|
add_to_group(GROUP)
|
|
_rng.randomize()
|
|
_cap = maxi(int(DP.f("blood_cap")), 0)
|
|
_build()
|
|
|
|
|
|
func _build() -> void:
|
|
var quad := QuadMesh.new()
|
|
quad.size = Vector2.ONE # unit quad; the instance basis carries the real size
|
|
|
|
var mat := ShaderMaterial.new()
|
|
mat.shader = load(SHADER_PATH) as Shader
|
|
mat.set_shader_parameter("atlas", _bake_atlas())
|
|
mat.set_shader_parameter("cells", ATLAS_CELLS)
|
|
material_override = mat
|
|
|
|
_mm = MultiMesh.new()
|
|
_mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
_mm.use_custom_data = true
|
|
_mm.mesh = quad
|
|
_mm.instance_count = _cap
|
|
# Godot renders only the first `visible_instance_count` instances, so unstained slots simply
|
|
# aren't drawn — no need to park them off-map. (Parking them via set_instance_transform doesn't
|
|
# work anyway: MultiMesh's per-instance buffer lives server-side and isn't reliably readable
|
|
# back through get_instance_transform, so a "hide by moving far away" scheme can't even be
|
|
# verified, let alone trusted.) Starts at 0 and grows as splats land.
|
|
_mm.visible_instance_count = 0
|
|
multimesh = _mm
|
|
|
|
|
|
## Stain the world at `world_pos`: one splat cluster on the floor beneath it, plus a splat
|
|
## on any wall within `blood_wall_reach`. `dir` is the spray heading (used to seed the wall
|
|
## fan); `size` is the base quad size in metres.
|
|
func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void:
|
|
if not is_instance_valid(_mm) or _cap <= 0:
|
|
return
|
|
var space := get_world_3d().direct_space_state
|
|
if space == null:
|
|
return
|
|
|
|
# Floor directly under the hit.
|
|
var floor_hit := _ray(space, world_pos + Vector3.UP * 0.5, world_pos + Vector3.DOWN * 4.0)
|
|
if not floor_hit.is_empty():
|
|
_stamp_cluster(floor_hit.position, floor_hit.normal, size)
|
|
|
|
# Walls near the hit: a fan of outward rays, seeded on the spray heading. Only surfaces
|
|
# within reach catch blood, so an open-arena hit stains nothing but the floor.
|
|
var rays := maxi(int(DP.f("blood_wall_rays")), 0)
|
|
if rays <= 0:
|
|
return
|
|
var reach := DP.f("blood_wall_reach")
|
|
var seed_dir := Vector3(dir.x, 0.0, dir.z)
|
|
seed_dir = seed_dir.normalized() if seed_dir.length_squared() > 0.01 else Vector3.FORWARD
|
|
var origin := world_pos + Vector3.UP * 0.6
|
|
for k in rays:
|
|
var ang := TAU * (float(k) + 0.5) / float(rays)
|
|
var out := seed_dir.rotated(Vector3.UP, ang)
|
|
var wall_hit := _ray(space, origin, origin + out * reach)
|
|
# Only stain near-vertical surfaces here; the floor is already handled above.
|
|
if not wall_hit.is_empty() and absf((wall_hit.normal as Vector3).y) < 0.6:
|
|
_stamp(wall_hit.position, wall_hit.normal, size * 0.85)
|
|
|
|
|
|
func _ray(space: PhysicsDirectSpaceState3D, from: Vector3, to: Vector3) -> Dictionary:
|
|
var q := PhysicsRayQueryParameters3D.create(
|
|
from, to, PhysicsLayers.WORLD | PhysicsLayers.CORPSE
|
|
)
|
|
q.collide_with_bodies = true
|
|
return space.intersect_ray(q)
|
|
|
|
|
|
# A main splat plus a scatter of smaller droplets around it, all lying on the same surface.
|
|
func _stamp_cluster(pos: Vector3, normal: Vector3, size: float) -> void:
|
|
_stamp(pos, normal, size)
|
|
var n := normal.normalized()
|
|
if n.length_squared() < 0.5:
|
|
n = Vector3.UP
|
|
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
|
var tx := up.cross(n).normalized()
|
|
var ty := n.cross(tx).normalized()
|
|
var count := maxi(int(DP.f("blood_satellites")), 0)
|
|
for s in count:
|
|
var rad := size * _rng.randf_range(0.4, 1.2)
|
|
var a := _rng.randf() * TAU
|
|
var off := tx * (cos(a) * rad) + ty * (sin(a) * rad)
|
|
_stamp(pos + off, n, size * _rng.randf_range(0.25, 0.5))
|
|
|
|
|
|
# Write one splat into the ring buffer, oriented flat on the surface and lifted a hair along
|
|
# its normal (layered by write index) so coplanar quads don't z-fight the surface or each other.
|
|
func _stamp(pos: Vector3, normal: Vector3, size: float) -> void:
|
|
var n := normal.normalized()
|
|
if n.length_squared() < 0.5:
|
|
n = Vector3.UP
|
|
var i := _next
|
|
_next = (_next + 1) % _cap
|
|
var eps := 0.015 + float(i) * 0.00015
|
|
var yaw := _rng.randf() * TAU
|
|
_mm.set_instance_transform(i, Transform3D(_surface_basis(n, yaw, size), pos + n * eps))
|
|
var cell := float(_rng.randi_range(0, ATLAS_CELLS - 1))
|
|
var shade := _rng.randf_range(0.6, 1.0)
|
|
_mm.set_instance_custom_data(i, Color(cell, shade, 0.0, 0.0))
|
|
_filled = mini(_filled + 1, _cap)
|
|
_mm.visible_instance_count = _filled
|
|
|
|
|
|
# Basis for a QuadMesh (face along local +Z) lying flat on a surface with the given normal,
|
|
# spun by `yaw` about that normal and uniformly scaled to `size`.
|
|
func _surface_basis(n: Vector3, yaw: float, size: float) -> Basis:
|
|
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
|
var x := up.cross(n).normalized()
|
|
var y := n.cross(x).normalized()
|
|
var c := cos(yaw)
|
|
var s := sin(yaw)
|
|
var b := Basis()
|
|
b.x = (x * c + y * s) * size
|
|
b.y = (y * c - x * s) * size
|
|
b.z = n
|
|
return b
|
|
|
|
|
|
# ── Atlas baking ──────────────────────────────────────────────────────────────
|
|
# Draw ATLAS_CELLS irregular blood shapes into one row. Each shape is a metaball field —
|
|
# a big central blob, a few overlapping lobes and some flung droplets — thresholded to an
|
|
# organic silhouette. Baked once per pool (once per level load, ~ms) with a fixed seed so
|
|
# the shape set is deterministic and every level's stains match.
|
|
|
|
func _bake_atlas() -> ImageTexture:
|
|
var img := Image.create(ATLAS_CELLS * CELL_PX, CELL_PX, false, Image.FORMAT_RGBA8)
|
|
img.fill(Color(0.0, 0.0, 0.0, 0.0))
|
|
var rng := RandomNumberGenerator.new()
|
|
rng.seed = hash("bullosseum-blood")
|
|
for c in ATLAS_CELLS:
|
|
_draw_splat(img, c * CELL_PX, rng)
|
|
return ImageTexture.create_from_image(img)
|
|
|
|
|
|
func _draw_splat(img: Image, ox: int, rng: RandomNumberGenerator) -> void:
|
|
var px := float(CELL_PX)
|
|
var mid := px * 0.5
|
|
# blobs are (centre_x, centre_y, radius) — a central mass, lobes, then far droplets.
|
|
var blobs: Array[Vector3] = []
|
|
blobs.append(Vector3(mid, mid, px * rng.randf_range(0.22, 0.30)))
|
|
for i in rng.randi_range(3, 6):
|
|
var a := rng.randf() * TAU
|
|
var d := px * rng.randf_range(0.10, 0.34)
|
|
blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.06, 0.16)))
|
|
for i in rng.randi_range(2, 5):
|
|
var a := rng.randf() * TAU
|
|
var d := px * rng.randf_range(0.30, 0.46)
|
|
blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.02, 0.05)))
|
|
|
|
for y in CELL_PX:
|
|
for x in CELL_PX:
|
|
var field := 0.0
|
|
for b in blobs:
|
|
var dx := float(x) - b.x
|
|
var dy := float(y) - b.y
|
|
field += (b.z * b.z) / (dx * dx + dy * dy + 1.0)
|
|
var a := smoothstep(0.75, 1.15, field)
|
|
if a <= 0.004:
|
|
continue
|
|
# Denser field (splat interior) reads a touch richer/darker than the thin edges.
|
|
var t := clampf(field * 0.5, 0.0, 1.0)
|
|
var col := Color(0.62 - 0.16 * t, 0.05 - 0.03 * t, 0.04 - 0.02 * t, a)
|
|
img.set_pixel(ox + x, y, col)
|