scopedvr-map-editor/library/terrain_arrays.gd
slyphax 40ca5ab6ba Terrain-Malen: weiche, organische Kanten statt 1-m-Pixelblöcke
Sly 25.10.: „immer noch harte Kanten beim Malen“. Ursache: die Splat-Textur
hatte beim feinen 1-m-Raster genau einen Texel je Materialpunkt und wird
nearest gesampelt → jede Zelle ein harter Block.

- Shader liest die Material-ID-Karte (R8, ein Texel je Punkt, texelFetch)
  und mischt PRO PIXEL bilinear über die 4 Nachbarpunkte.
- Leichter Domain-Warp (Value-Noise in Rasterzellen, eine Oktave) verwackelt
  die Abtaststelle → keine Rastertreppen, natürliche Kante.
- Malen aktualisiert nur noch die Bytes der Karte (kein Splat-Neuberechnen).
- TerrainArrays.material_image (Spiegel-Datei, identisch im Client).
- Render-Probe tests/probe_mal_weich_render.gd (vorher/nachher geprüft).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:23:18 +02:00

221 lines
8.5 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Resource
class_name TerrainArrays
## Träger-Resource für die Terrain-Texture2DArrays (Karte t_ab408127).
## GLEICHER Pfad in Client UND Editor (res://library/terrain_arrays.gd) —
## die gespeicherte .res verweist auf den Script-Pfad, der muss in beiden
## Repos auflösen (Repos sind entkoppelt, therefore identischer Ort).
##
## Warum diese Klasse: ResourceSaver speichert die internen Images eines
## Texture2DArray NICHT (gespeicherte .res/.tres enthält 20× null — 20.10.
## bewiesen). Images in einer eigenen exportierten Property dagegen landen
## zuverlässig in der .res-Datei. Der Verbraucher baut beim LOAD aus diesen
## Images sofort die Texture2DArrays (create_from_images) — die Images sind
## bereits ETC2-komprimiert (Quest-3-nativ, ~1 MB/Layer statt 4 MB RGBA8),
## der Bau ist also nur ein GPU-Upload, kein Rekomprimieren.
@export var albedo_images: Array[Image] = []
@export var normal_images: Array[Image] = []
@export var tile_size := 1.0 # Meter pro Kachel (Worldspace-Tiling)
# ============================ SPLAT-GEWICHTS-ABLEITUNG ======================
# Karte t_7a0a6b79 (weiche Übergänge): der alte Shader pickte pro Pixel GENAU
# EINE Textur-Schicht über die Vertex-Alpha (floor+0.5-Snap) — an Dreieck-
# kanten sprang die GPU-interpolierte Alpha hart zwischen Material-IDs
# (blockartige, dreieckstreppige Grenzen). NEU: aus der Zell-Schicht
# (materials_r8, res²) wird eine SPLAT-GEWICHTS-TEXTUR abgeleitet — je Texel
# die Top-2-Zell-IDs mit bilinearen Kernel-Gewichten:
# R = id0 (0..N-1), G = w0, B = id1, A = w1 (w0 + w1 = 1 normalisiert)
# Der Shader sampelt die Splat-Textur (FILTER_NEAREST — IDs dürfen NICHT
# bilinear vermischt werden; die Weichheit steckt IN den Gewichten) und mischt
# die zwei Layer: mix(tex(id1), tex(id0), w0). Datenformat UNVERÄNDERT — die
# Zell-Schicht bleibt die Quelle der Wahrheit.
#
# Texel-Dichte: SPLAT_TEXELS_PER_CELL Texel je Terrain-Zelle (4 × res−1) UND
# mindestens SPLAT_TARGET_M_PER_TEXEL Meter je Texel (Karte t_5c94d098 —
# Messung: 8 m/Zelle × 4 = 2 m/Texel bei der 1024-m-Standardmap ergab eine
# in 2-m-Stufen gerasterte Gewichtsrampe, sichtbar als Bänder/Stufen quer zur
# Materialgrenze), als Zweierpotenz geklemmt auf [SPLAT_SIDE_MIN, SPLAT_SIDE_MAX]
# — beim Malen kann dann ein kleiner Sub-Rect neu abgeleitet werden (Live-Pfad).
const SPLAT_TEXELS_PER_CELL := 4
const SPLAT_SIDE_MIN := 256
const SPLAT_SIDE_MAX := 1024
const SPLAT_TARGET_M_PER_TEXEL := 1.0
## Splat-Textur-Kantengröße für ein res×res-Zellraster (Zweierpotenz).
## map_size_m = Kantenlänge der Map in Weltmetern (optional, 0 = nicht gegeben):
## die Karten-GRÖSSE begrenzt die erreichbare Weichheit, nicht die Zellzahl —
## dieselbe Zellzahl liefert auf einer großen Map viel gröbere Texel.
## Ergebnis (Budget-geklemmt, 4 MB RGBA8 bei 1024²): 1024 m → 1024 (1 m/Texel),
## 256 m → 512 (0,5 m/Texel), 4096 m → 1024 (4 m/Texel, Klemme).
static func splat_side_for(res: int, map_size_m: float = 0.0) -> int:
var target := maxi(1, res - 1) * SPLAT_TEXELS_PER_CELL
if map_size_m > 0.0:
target = maxi(target, int(ceilf(map_size_m / SPLAT_TARGET_M_PER_TEXEL)))
var side := SPLAT_SIDE_MIN
while side < target:
side *= 2
return mini(side, SPLAT_SIDE_MAX)
## Splat-Gewichts-Bytes (RGBA8, row-major) für die Zell-Schicht ableiten.
## rect = Texel-Rechteck (position/size im Bild) — leer = ganzes Bild.
## Bilinearer Kernel über die 4 umliegenden Zellen: an einer Zellgrenze rampen
## die Gewichte weich 0→1 (Weichheit = eine Zellbreite, wie in der Spec).
## Test-Vertrag: identische Formel in Client UND Editor (Spiegel-Datei).
static func build_splat_bytes(mats: PackedByteArray, res: int, side: int,
rect: Rect2i = Rect2i()) -> PackedByteArray:
var out := PackedByteArray()
if res < 2 or side < 1 or mats.size() < res * res:
return out
var rx := 0
var ry := 0
var rw := side
var rh := side
if rect.size.x > 0 and rect.size.y > 0:
rx = clampi(rect.position.x, 0, side - 1)
ry = clampi(rect.position.y, 0, side - 1)
rw = clampi(rect.size.x, 1, side - rx)
rh = clampi(rect.size.y, 1, side - ry)
out.resize(rw * rh * 4)
var last := float(res - 1)
# Achsen-Gewichte einmal vorrechnen (hängen nur von der Texel-Spalte/Zeile ab):
var ix0 := PackedInt32Array()
var ix1 := PackedInt32Array()
var fx := PackedFloat32Array()
ix0.resize(rw)
ix1.resize(rw)
fx.resize(rw)
for i in rw:
var t := (float(rx + i) + 0.5) / float(side) * last
var i0 := clampi(int(floorf(t)), 0, res - 1)
ix0[i] = i0
ix1[i] = mini(i0 + 1, res - 1)
fx[i] = t - float(i0)
var iz0 := PackedInt32Array()
var iz1 := PackedInt32Array()
var fz := PackedFloat32Array()
iz0.resize(rh)
iz1.resize(rh)
fz.resize(rh)
for j in rh:
var t := (float(ry + j) + 0.5) / float(side) * last
var j0 := clampi(int(floorf(t)), 0, res - 1)
iz0[j] = j0
iz1[j] = mini(j0 + 1, res - 1)
fz[j] = t - float(j0)
var ofs := 0
# Wiederverwendete Kandidaten-Slots: die Ableitung läuft bei 1024² über
# 1 Mio. Texel — Allokationen je Texel (alte Fassung: 2 Packed-Arrays +
# Array-Literale) kosteten messbar Zeit (Karte t_5c94d098).
var cids := PackedInt32Array()
var cws := PackedFloat32Array()
var mids := PackedInt32Array()
var mws := PackedFloat32Array()
cids.resize(4)
cws.resize(4)
mids.resize(4)
mws.resize(4)
for j in rh:
var wz0 := 1.0 - fz[j]
var wz1 := fz[j]
var row0 := iz0[j] * res
var row1 := iz1[j] * res
for i in rw:
var wx0 := 1.0 - fx[i]
var wx1 := fx[i]
# Die vier Nachbarzellen (bilineare Gewichte) — Reihenfolge identisch
# zur Vorversion (Referenz-Checks in tests/test_terrain_splat.gd).
cids[0] = int(mats[row0 + ix0[i]])
cids[1] = int(mats[row0 + ix1[i]])
cids[2] = int(mats[row1 + ix0[i]])
cids[3] = int(mats[row1 + ix1[i]])
cws[0] = wx0 * wz0
cws[1] = wx1 * wz0
cws[2] = wx0 * wz1
cws[3] = wx1 * wz1
var a0 := cids[0]
if a0 == cids[1] and a0 == cids[2] and a0 == cids[3]:
# SCHNELLPFAD (Loch im Material, der Regelfall): alle vier Zellen
# dieselbe ID → w0 = 1, keine Mischung. Überspringt das
# Zusammenziehen der Gewichte (innerhalb einer Materialfläche
# praktisch jeder Texel, macht die 1024²-Ableitung bezahlbar).
out.encode_u8(ofs, a0)
out.encode_u8(ofs + 1, 255)
out.encode_u8(ofs + 2, a0)
out.encode_u8(ofs + 3, 0)
ofs += 4
continue
# Duplikat-IDs zusammenziehen (Gewichte aufsummieren):
var n := 0
for c in 4:
var w := cws[c]
if w <= 0.0:
continue
var cid := cids[c]
var found := false
for k in n:
if mids[k] == cid:
mws[k] += w
found = true
break
if not found:
mids[n] = cid
mws[n] = w
n += 1
if n == 0:
# Kann rechnerisch nicht auftreten (die 4 Gewichte summieren zu 1);
# defensiv: Texel = Layer 0 mit vollem Gewicht.
out.encode_u8(ofs, 0)
out.encode_u8(ofs + 1, 255)
out.encode_u8(ofs + 2, 0)
out.encode_u8(ofs + 3, 0)
ofs += 4
continue
# Top-2 IDs nach Gewicht (Fenster von max. 4 Kandidaten):
var id0 := mids[0]
var w0 := mws[0]
var id1 := id0
var w1 := 0.0
for k in range(1, n):
if mws[k] > w0:
id1 = id0
w1 = w0
id0 = mids[k]
w0 = mws[k]
elif mws[k] > w1 or id1 == id0:
id1 = mids[k]
w1 = mws[k]
# Normalisieren (w0 + w1 = 1) und 8-bit-packen:
var wsum := maxf(w0 + w1, 0.0001)
var b0 := clampi(roundi(w0 / wsum * 255.0), 0, 255)
out.encode_u8(ofs, clampi(id0, 0, 255))
out.encode_u8(ofs + 1, b0)
out.encode_u8(ofs + 2, clampi(id1, 0, 255))
out.encode_u8(ofs + 3, 255 - b0)
ofs += 4
return out
## Ein Splat-Texel dekodieren (für Tests): → [id0, w0, id1, w1] (w0 ∈ 0..1).
static func decode_splat_texel(bytes: PackedByteArray, texel_ofs: int) -> Array:
return [
int(bytes.decode_u8(texel_ofs)),
float(bytes.decode_u8(texel_ofs + 1)) / 255.0,
int(bytes.decode_u8(texel_ofs + 2)),
float(bytes.decode_u8(texel_ofs + 3)) / 255.0,
]
## Material-ID-Karte für den Shader (Karte 25.10., weiche Übergänge pro Pixel):
## R8, ein Texel je Materialpunkt (mres²) — der Shader liest per texelFetch die
## 4 umliegenden IDs und mischt bilinear. Ersetzt im Rendering die Splat-
## Ableitung (build_splat_bytes bleibt für Probes/Tests). Unpassende Schicht →
## 2×2 alles Gras (Shader bleibt nie ohne Textur).
static func material_image(mats: PackedByteArray, mres: int) -> Image:
if mres < 2 or mats.size() != mres * mres:
var leer := PackedByteArray()
leer.resize(4)
return Image.create_from_data(2, 2, false, Image.FORMAT_R8, leer)
return Image.create_from_data(mres, mres, false, Image.FORMAT_R8, mats)