The visual-win commit that wires up the Phase 3c.1/.2/.3 building blocks:
Holtburg's terrain now uses AC's real per-cell texture-merge blend
(base + up to 3 terrain overlays + up to 2 road overlays, with alpha
masks from the alpha atlas) instead of the flat per-vertex single-layer
atlas lookup that preceded it.
Geometry rewrite:
- New TerrainVertex struct (40 bytes): Position(vec3) + Normal(vec3) +
Data0..3 (4x uint32 packed blend recipe)
- LandblockMesh.Build is now cell-based: iterates 8x8 cells instead of
the old 9x9 vertex grid, emits 6 vertices per cell (two triangles),
384 total vertices per landblock
- For each cell: extract 4-corner terrain/road values → GetPalCode →
BuildSurface (cached across landblocks via a shared surfaceCache) →
FillCellData → split direction from CalculateSplitDirection → emit
6 vertices in the exact gl_VertexID % 6 order WorldBuilder's vertex
shader expects
- Per-vertex normals preserved via Phase 3b central-difference
precomputation on the 9x9 heightmap, interpolated smoothly across
the cell (we deliberately didn't adopt WorldBuilder's dFdx/dFdy
flat-shade approach — Phase 3a/3b user-tuned lighting was worth
keeping)
Renderer rewrite:
- TerrainRenderer VAO: vec3 Position, vec3 Normal, 4x uvec4 byte
attributes for Data0..3. The uvec4-of-bytes read pattern matches
Landscape.vert so the ported shader math stays byte-for-byte
identical to WorldBuilder's.
- Binds both atlases: terrain atlas on unit 0 (uTerrain), alpha atlas
on unit 1 (uAlpha)
Shader rewrite (ports of WorldBuilder Landscape.vert/.frag, trimmed):
- terrain.vert: unpacks the 4 data bytes + rotation bits, derives the
cell corner from gl_VertexID % 6 + splitDir, rotates the cell-local
UV per overlay's rotation field, and computes world-space normal
for the fragment shader
- terrain.frag: maskBlend3 three-layer alpha-weighted composite for
terrain overlays, inverted-alpha road combine, final composite
base * (1-ovlA)*(1-rdA) + ovl * ovlA*(1-rdA) + road * rdA. Phase
3a/3b directional lighting applied on top (SUN_DIR, AMBIENT=0.25,
DIFFUSE=0.75, in sync with mesh.frag).
- Editor uniforms (grid, brush, unwalkable slopes) deliberately
omitted — not applicable to a game client
- Per-texture tiling factor hardcoded to 1.0 for now (WorldBuilder
reads it from uTexTiling[36] uploaded from the dats); one tile per
cell = 8 tiles per landblock-side, slightly coarser than the old
~2x-per-cell tiling. Tunable via the TILE constant if needed.
TerrainAtlas grew parallel TCode/RCode lists (CornerAlphaTCodes,
SideAlphaTCodes, RoadAlphaRCodes) so TerrainBlendingContext can be
built without the mesh loader touching the dats directly.
GameWindow builds a TerrainBlendingContext once, shares a Dictionary
<uint, SurfaceInfo> surfaceCache across all 9 landblocks. Output:
"terrain: 137 unique palette codes across 9 landblocks" — avg ~15
unique per landblock, cache reuse healthy.
LandblockMeshTests rewritten for 384-vertex layout. 77/77 tests green.
Visual smoke run launches clean: no shader compile/link errors, no
GL warnings, terrain renders to the screen.
User visual verification is the final acceptance gate for Phase 3c.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
386 lines
16 KiB
C#
386 lines
16 KiB
C#
using AcDream.Core.Textures;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Enums;
|
||
using Silk.NET.OpenGL;
|
||
using DatPixelFormat = DatReaderWriter.Enums.PixelFormat;
|
||
using GLPixelFormat = Silk.NET.OpenGL.PixelFormat;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>
|
||
/// Holds both texture arrays the terrain renderer samples from:
|
||
/// <list type="bullet">
|
||
/// <item><description>
|
||
/// <b>Terrain atlas</b> — one GL_TEXTURE_2D_ARRAY layer per terrain type
|
||
/// (grass, dirt, sand, forest...), sourced from
|
||
/// Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// <b>Alpha atlas</b> — one GL_TEXTURE_2D_ARRAY layer per blend mask,
|
||
/// sourced from CornerTerrainMaps / SideTerrainMaps / RoadMaps in the
|
||
/// same TexMerge. Used by the fragment shader to blend up to three
|
||
/// terrain overlays and two roads on top of a base cell texture.
|
||
/// </description></item>
|
||
/// </list>
|
||
/// The alpha atlas is built but not yet sampled by any shader — that wiring
|
||
/// lands in Phase 3c.4 along with the shader rewrite.
|
||
/// </summary>
|
||
public sealed unsafe class TerrainAtlas : IDisposable
|
||
{
|
||
private readonly GL _gl;
|
||
|
||
// --- Terrain atlas (unchanged public API from Phase 2b) ---
|
||
public uint GlTexture { get; } // terrain atlas, kept as GlTexture for back-compat with TerrainRenderer
|
||
public IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { get; }
|
||
public int LayerCount { get; }
|
||
|
||
// --- Alpha atlas (new in Phase 3c.2) ---
|
||
public uint GlAlphaTexture { get; }
|
||
public int AlphaLayerCount { get; }
|
||
/// <summary>Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries).</summary>
|
||
public IReadOnlyList<byte> CornerAlphaLayers { get; }
|
||
/// <summary>Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries).</summary>
|
||
public IReadOnlyList<byte> SideAlphaLayers { get; }
|
||
/// <summary>Layer indices in the alpha atlas for RoadMaps (variable count).</summary>
|
||
public IReadOnlyList<byte> RoadAlphaLayers { get; }
|
||
|
||
// --- Parallel TCode/RCode arrays (added in Phase 3c.4 for BuildSurface) ---
|
||
/// <summary>TCode for each CornerTerrainMap, parallel to <see cref="CornerAlphaLayers"/>.</summary>
|
||
public IReadOnlyList<uint> CornerAlphaTCodes { get; }
|
||
/// <summary>TCode for each SideTerrainMap, parallel to <see cref="SideAlphaLayers"/>.</summary>
|
||
public IReadOnlyList<uint> SideAlphaTCodes { get; }
|
||
/// <summary>RCode for each RoadMap, parallel to <see cref="RoadAlphaLayers"/>.</summary>
|
||
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
|
||
|
||
private TerrainAtlas(
|
||
GL gl,
|
||
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
|
||
uint glAlphaTexture, int alphaLayerCount,
|
||
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
|
||
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes)
|
||
{
|
||
_gl = gl;
|
||
GlTexture = glTexture;
|
||
TerrainTypeToLayer = map;
|
||
LayerCount = layerCount;
|
||
GlAlphaTexture = glAlphaTexture;
|
||
AlphaLayerCount = alphaLayerCount;
|
||
CornerAlphaLayers = cornerLayers;
|
||
SideAlphaLayers = sideLayers;
|
||
RoadAlphaLayers = roadLayers;
|
||
CornerAlphaTCodes = cornerTCodes;
|
||
SideAlphaTCodes = sideTCodes;
|
||
RoadAlphaRCodes = roadRCodes;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build the atlas by walking Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
|
||
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
|
||
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
|
||
/// </summary>
|
||
public static TerrainAtlas Build(GL gl, DatCollection dats)
|
||
{
|
||
var region = dats.Get<Region>(0x13000000u)
|
||
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
|
||
|
||
var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge;
|
||
var terrainDesc = texMerge?.TerrainDesc;
|
||
if (terrainDesc is null || terrainDesc.Count == 0)
|
||
{
|
||
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
|
||
return BuildFallback(gl);
|
||
}
|
||
|
||
// ---- Terrain atlas (unchanged Phase 2b logic) ----
|
||
var decodedByType = new Dictionary<uint, DecodedTexture>();
|
||
int maxW = 1, maxH = 1;
|
||
foreach (var tmtd in terrainDesc)
|
||
{
|
||
uint typeKey = (uint)tmtd.TerrainType;
|
||
if (decodedByType.ContainsKey(typeKey))
|
||
continue;
|
||
|
||
var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId;
|
||
var st = dats.Get<SurfaceTexture>(surfaceTextureId);
|
||
if (st is null || st.Textures.Count == 0)
|
||
{
|
||
Console.WriteLine($"WARN: TerrainType {tmtd.TerrainType} SurfaceTexture 0x{surfaceTextureId:X8} missing");
|
||
decodedByType[typeKey] = DecodedTexture.Magenta;
|
||
continue;
|
||
}
|
||
|
||
var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
|
||
if (rs is null)
|
||
{
|
||
decodedByType[typeKey] = DecodedTexture.Magenta;
|
||
continue;
|
||
}
|
||
|
||
Palette? palette = rs.DefaultPaletteId != 0
|
||
? dats.Get<Palette>(rs.DefaultPaletteId)
|
||
: null;
|
||
|
||
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
|
||
decodedByType[typeKey] = decoded;
|
||
if (decoded.Width > maxW) maxW = decoded.Width;
|
||
if (decoded.Height > maxH) maxH = decoded.Height;
|
||
}
|
||
|
||
int layerCount = decodedByType.Count;
|
||
uint tex = gl.GenTexture();
|
||
gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
||
gl.TexImage3D(
|
||
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
|
||
(uint)maxW, (uint)maxH, (uint)layerCount,
|
||
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
||
|
||
var map = new Dictionary<uint, uint>();
|
||
int layerIdx = 0;
|
||
foreach (var kvp in decodedByType)
|
||
{
|
||
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
|
||
fixed (byte* p = buffer)
|
||
{
|
||
gl.TexSubImage3D(
|
||
TextureTarget.Texture2DArray, 0,
|
||
0, 0, layerIdx,
|
||
(uint)maxW, (uint)maxH, 1,
|
||
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||
}
|
||
map[kvp.Key] = (uint)layerIdx;
|
||
layerIdx++;
|
||
}
|
||
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
|
||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
||
|
||
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH}");
|
||
|
||
// ---- Alpha atlas (new in Phase 3c.2) ----
|
||
// texMerge is guaranteed non-null here: the early return above exited
|
||
// if texMerge?.TerrainDesc was null.
|
||
var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!);
|
||
|
||
return new TerrainAtlas(
|
||
gl,
|
||
tex, map, layerCount,
|
||
alphaBuild.gl, alphaBuild.layerCount,
|
||
alphaBuild.corner, alphaBuild.side, alphaBuild.road,
|
||
alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Load corner, side, and road alpha maps from the TexMerge into a second
|
||
/// GL_TEXTURE_2D_ARRAY. AC ships these as 512×512 PFID_A8 textures;
|
||
/// <see cref="SurfaceDecoder.DecodeRenderSurface"/> expands each alpha byte
|
||
/// into all four RGBA channels so the shader can sample from any channel.
|
||
///
|
||
/// Layers are appended in TexMerge insertion order: corners first, then
|
||
/// sides, then roads. The returned index lists tell
|
||
/// <c>TerrainBlending.BuildSurface</c> which layer to cite for each
|
||
/// corner/side/road alpha source.
|
||
/// </summary>
|
||
private readonly record struct AlphaAtlasBuildResult(
|
||
uint gl, int layerCount,
|
||
IReadOnlyList<byte> corner, IReadOnlyList<byte> side, IReadOnlyList<byte> road,
|
||
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
|
||
|
||
private static AlphaAtlasBuildResult BuildAlphaAtlas(
|
||
GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge)
|
||
{
|
||
var decoded = new List<DecodedTexture>();
|
||
var cornerLayers = new List<byte>();
|
||
var sideLayers = new List<byte>();
|
||
var roadLayers = new List<byte>();
|
||
var cornerTCodes = new List<uint>();
|
||
var sideTCodes = new List<uint>();
|
||
var roadRCodes = new List<uint>();
|
||
|
||
foreach (var entry in texMerge.CornerTerrainMaps)
|
||
{
|
||
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
|
||
{
|
||
cornerLayers.Add((byte)decoded.Count);
|
||
cornerTCodes.Add(entry.TCode);
|
||
decoded.Add(dtex);
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($"WARN: CornerTerrainMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
|
||
}
|
||
}
|
||
foreach (var entry in texMerge.SideTerrainMaps)
|
||
{
|
||
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
|
||
{
|
||
sideLayers.Add((byte)decoded.Count);
|
||
sideTCodes.Add(entry.TCode);
|
||
decoded.Add(dtex);
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($"WARN: SideTerrainMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
|
||
}
|
||
}
|
||
foreach (var entry in texMerge.RoadMaps)
|
||
{
|
||
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
|
||
{
|
||
roadLayers.Add((byte)decoded.Count);
|
||
roadRCodes.Add(entry.RCode);
|
||
decoded.Add(dtex);
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($"WARN: RoadMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
|
||
}
|
||
}
|
||
|
||
if (decoded.Count == 0)
|
||
{
|
||
Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
|
||
uint fallbackAlpha = gl.GenTexture();
|
||
gl.BindTexture(TextureTarget.Texture2DArray, fallbackAlpha);
|
||
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0,
|
||
GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
||
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
|
||
fixed (byte* p = white)
|
||
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1,
|
||
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
||
return new AlphaAtlasBuildResult(
|
||
fallbackAlpha, 1,
|
||
cornerLayers, sideLayers, roadLayers,
|
||
cornerTCodes, sideTCodes, roadRCodes);
|
||
}
|
||
|
||
// Alpha maps should all be uniform size (WorldBuilder asserts 512×512).
|
||
// Fall back to the max observed so a stray mismatch doesn't crash us.
|
||
int aMaxW = 1, aMaxH = 1;
|
||
foreach (var d in decoded)
|
||
{
|
||
if (d.Width > aMaxW) aMaxW = d.Width;
|
||
if (d.Height > aMaxH) aMaxH = d.Height;
|
||
}
|
||
|
||
uint glAlpha = gl.GenTexture();
|
||
gl.BindTexture(TextureTarget.Texture2DArray, glAlpha);
|
||
gl.TexImage3D(
|
||
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
|
||
(uint)aMaxW, (uint)aMaxH, (uint)decoded.Count,
|
||
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
||
|
||
for (int i = 0; i < decoded.Count; i++)
|
||
{
|
||
var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH);
|
||
fixed (byte* p = buffer)
|
||
{
|
||
gl.TexSubImage3D(
|
||
TextureTarget.Texture2DArray, 0,
|
||
0, 0, i,
|
||
(uint)aMaxW, (uint)aMaxH, 1,
|
||
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||
}
|
||
}
|
||
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
|
||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
||
|
||
Console.WriteLine(
|
||
$"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} "
|
||
+ $"(corners={cornerLayers.Count}, sides={sideLayers.Count}, roads={roadLayers.Count})");
|
||
|
||
return new AlphaAtlasBuildResult(
|
||
glAlpha, decoded.Count,
|
||
cornerLayers, sideLayers, roadLayers,
|
||
cornerTCodes, sideTCodes, roadRCodes);
|
||
}
|
||
|
||
private static bool TryDecodeAlphaMap(DatCollection dats, uint surfaceTextureId, out DecodedTexture decoded)
|
||
{
|
||
decoded = DecodedTexture.Magenta;
|
||
|
||
var st = dats.Get<SurfaceTexture>(surfaceTextureId);
|
||
if (st is null || st.Textures.Count == 0)
|
||
return false;
|
||
|
||
var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
|
||
if (rs is null)
|
||
return false;
|
||
|
||
// Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha
|
||
// format) or the more generic PFID_A8; SurfaceDecoder routes both
|
||
// through the same "replicate single byte to RGBA" path. Palette is
|
||
// not used.
|
||
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||
if (ReferenceEquals(d, DecodedTexture.Magenta))
|
||
return false;
|
||
|
||
decoded = d;
|
||
return true;
|
||
}
|
||
|
||
private static byte[] ResizeRgba8Nearest(DecodedTexture src, int dstW, int dstH)
|
||
{
|
||
if (src.Width == dstW && src.Height == dstH)
|
||
return src.Rgba8;
|
||
|
||
var dst = new byte[dstW * dstH * 4];
|
||
for (int y = 0; y < dstH; y++)
|
||
{
|
||
int srcY = y * src.Height / dstH;
|
||
for (int x = 0; x < dstW; x++)
|
||
{
|
||
int srcX = x * src.Width / dstW;
|
||
int si = (srcY * src.Width + srcX) * 4;
|
||
int di = (y * dstW + x) * 4;
|
||
dst[di + 0] = src.Rgba8[si + 0];
|
||
dst[di + 1] = src.Rgba8[si + 1];
|
||
dst[di + 2] = src.Rgba8[si + 2];
|
||
dst[di + 3] = src.Rgba8[si + 3];
|
||
}
|
||
}
|
||
return dst;
|
||
}
|
||
|
||
private static TerrainAtlas BuildFallback(GL gl)
|
||
{
|
||
uint tex = gl.GenTexture();
|
||
gl.BindTexture(TextureTarget.Texture2DArray, tex);
|
||
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
|
||
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
||
fixed (byte* p = white)
|
||
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
|
||
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
|
||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
||
|
||
// Fallback alpha atlas: 1x1 white, no layers tracked
|
||
uint alphaTex = gl.GenTexture();
|
||
gl.BindTexture(TextureTarget.Texture2DArray, alphaTex);
|
||
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
|
||
fixed (byte* p = white)
|
||
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
|
||
gl.BindTexture(TextureTarget.Texture2DArray, 0);
|
||
|
||
return new TerrainAtlas(
|
||
gl,
|
||
tex, new Dictionary<uint, uint> { [0] = 0u }, 1,
|
||
alphaTex, 1,
|
||
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
|
||
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
_gl.DeleteTexture(GlTexture);
|
||
_gl.DeleteTexture(GlAlphaTexture);
|
||
}
|
||
}
|