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>
221 lines
8.5 KiB
GDScript
221 lines
8.5 KiB
GDScript
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)
|