From ae6513126e20d6ee26b11325bcff00bb724e76b1 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 22 Aug 2026 22:39:58 +0200 Subject: [PATCH] fix(render): detail overlay is fogged after the combine like retail; VM1 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus dual-lens review of 05970306 + 388457a7 (APPROVE WITH FIXES). Four items, all landed: 1. FOG (behavioural). Retail's D3D fixed-function fog stage runs AFTER the texture-stage pipeline, so the detail contribution must be fogged, not just the base. mesh_modern.frag already fogs the base colour (applyFog(rgb, vWorldPos)) before mesh_detail's replay draws over it; mesh_detail.frag previously emitted raw detail.rgb, understating fog by f*a*(fog-detail). Fix: mesh_detail.vert now outputs vWorldPos (mirroring mesh_modern.vert); mesh_detail.frag declares the identical SceneLighting UBO and applyFog function (copied verbatim, same binding/std140/math) and fogs detail.rgb before emitting it. This collapses algebraically to retail's fog-after-combine order: (1-a)*mix(base,fog,f) + a*mix(detail,fog,f) = mix(lerp(base,detail,a),fog,f) RetailDetailTextureContract gains ExpectedFogged(base,detail,opacity,fog, fogFactor); RetailDetailTextureContractTests pins the identity across 200 random samples within 1e-6. 2. EnvCellRenderer.Rhi.cs's DrawEnvCell-category comment still said "apply the 10-50 m positive-view-depth fade" — a stale claim from before VM1 removed the fade. Replaced with the mip-chain attenuation statement that mesh_detail.vert's header comment already carries. 3. Added the test the VM1 contract required but never had: TerrainAtlas .TryCreateDetailTexture uploads a full mip chain (MipLevelCount == RhiWorldTextureArray.MipLevelsFor(w,h), GenerateMipChain called) and registers with the repeat/linear world sampler, not single-level or clamped. Drives the private method directly (reflection) against a synthetic PFID_A8R8G8B8 RenderSurface through a minimal in-memory IDatReaderWriter fake, so the lane stays hermetic (no installed DAT). 4. #226 pseudocode note: noted that retail's stage-1 OUTPUT alpha (MODULATE(TEXTURE, CURRENT), 0x0059c549) — the framebuffer blend weight a delayed-alpha subset composites with — is not modelled; acdream instead draws a second pass weighted by detail.a*diffuseAlpha. Identical for opaque subsets, a bounded difference on translucent building/EnvCell subsets already covered by the existing AP-34 shared-alpha-queue divergence row. Also qualified the tmpmaterial.Diffuse.a = 1f (0x0059cb99) citation to name its exact branch (burnedInStaticLights < 0 && *(render_device+0x7e4) == 0); the other branch leaves diffuse FromVertex, but the opaque->1 / fading->opacity mapping still holds either way. Nit also folded in: EnvCellRendererTests' new SubmitRhi instance-alpha test is now a [Theory] over WbRenderPass.Opaque and .Transparent, pinning the bind-before-first-draw invariant on both passes. Regenerated mesh_detail's committed SPIR-V and the shader manifest (tools/compile-shaders.ps1); no other shader pair changed. Verified: dotnet build AcDream.slnx -c Release (0 warnings, 0 errors); dotnet test on AcDream.App.Tests (Release, hermetic lanes) green, including the shader manifest tests explicitly; AcDream.Core.Tests unaffected/green. Co-Authored-By: Claude Sonnet 5 --- ...il-building-detail-texturing-pseudocode.md | 26 ++- .../Rendering/RetailDetailTextureContract.cs | 29 +++ .../Rendering/Shaders/mesh_detail.frag | 53 +++++- .../Rendering/Shaders/mesh_detail.vert | 3 + .../Shaders/spv/mesh_detail.frag.spv | Bin 2204 -> 3448 bytes .../Shaders/spv/mesh_detail.vert.spv | Bin 4468 -> 4564 bytes .../Shaders/spv/shaders.manifest.json | 4 +- .../Rendering/Wb/EnvCellRenderer.Rhi.cs | 10 +- .../RetailDetailTextureContractTests.cs | 42 +++++ .../TerrainAtlasDetailTextureTests.cs | 173 ++++++++++++++++++ .../Rendering/Wb/EnvCellRendererTests.cs | 17 +- 11 files changed, 343 insertions(+), 14 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs diff --git a/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md b/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md index 0bf362d9..9ea8f024 100644 --- a/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md +++ b/docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md @@ -105,9 +105,17 @@ for each replayed fragment: detail = sample(categoryTexture, baseUv * categoryTiling) diffuseAlpha = base_subset_diffuse_alpha // 1 for opaque; the // translucency-fade multiplier - // for a fading subset - // (tmpmaterial.Diffuse.a in - // RenderMeshSubset) + // for a fading subset. + // tmpmaterial.Diffuse.a = 1f + // (0x0059cb99) is the + // burnedInStaticLights < 0 && + // *(render_device+0x7e4) == 0 + // branch in RenderMeshSubset; + // the other branch leaves + // diffuse FromVertex. Either + // way the opaque->1 / + // fading->opacity mapping + // still holds. // D3DPolyRender::SetSurface (0x0059c4d0) texture-stage setup: // stage 0 colour = MODULATE(TEXTURE, DIFFUSE) = base.rgb * diffuse.rgb @@ -176,6 +184,18 @@ change base coverage/blend/depth behavior; it avoids making the checkbox reorder the default transparent scene. The connected acceptance matrix must still exercise overlapping transparent building/EnvCell surfaces. +Also unmodelled: retail's stage-1 OUTPUT alpha — `MODULATE(TEXTURE, CURRENT)` +(`0x0059c549`) — which for a delayed-alpha subset becomes the framebuffer +blend weight the alpha queue composites that subset with. acdream instead +draws the base subset with its own alpha and a second, separately blended +draw weighted by `detail.a * diffuseAlpha` (the pipeline in +`VulkanViewportMapping.BlendFactorsOf(GpuBlendMode.RetailDetail)`). For +opaque subsets this is identical (both reduce to the base's own alpha +gating nothing else downstream); on translucent building/EnvCell subsets it +is a bounded difference in how much the SUBSEQUENT alpha-queue compositing +sees, already covered by the existing AP-34 shared-alpha-queue divergence +row rather than a new one. + ## Darkening, not brightening The earlier version of this note read the two-pass fallback's `DEST_COLOR + diff --git a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs index c647d5ab..36fc600a 100644 --- a/src/AcDream.App/Rendering/RetailDetailTextureContract.cs +++ b/src/AcDream.App/Rendering/RetailDetailTextureContract.cs @@ -77,4 +77,33 @@ internal static class RetailDetailTextureContract /// internal static bool IsNeutral(Vector4 detail, float opacity) => detail.W * opacity == 0f; + + /// + /// Review fix (post-05970306): retail's D3D fog stage runs AFTER the + /// texture-stage combine, applying to the FINAL pixel, not to + /// detail.rgb in isolation. acdream draws the combine as two + /// separate passes (mesh_modern's base draw, then mesh_detail's blended + /// replay), so each draw fogs its OWN colour before the fixed-function + /// blend recombines them — this is the CPU statement of that two-draw + /// path: lerp(mix(base,fog,f), mix(detail,fog,f), detail.a*opacity). + /// It is algebraically identical to retail's single-draw + /// fog-after-combine order, mix(Expected(base,detail,opacity), fog, + /// f) — see RetailDetailTextureContractTests for the identity + /// pinned numerically, and mesh_detail.frag's header comment for the + /// derivation. + /// + internal static Vector3 ExpectedFogged( + Vector3 baseColour, + Vector4 detail, + float opacity, + Vector3 fog, + float fogFactor) + { + Vector3 foggedBase = Vector3.Lerp(baseColour, fog, fogFactor); + Vector3 foggedDetail = Vector3.Lerp( + new Vector3(detail.X, detail.Y, detail.Z), + fog, + fogFactor); + return Vector3.Lerp(foggedBase, foggedDetail, detail.W * opacity); + } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag index 7a0ec49b..380c0c22 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.frag @@ -4,6 +4,7 @@ in vec2 vBaseUv; in vec2 vDetailUv; in float vDetailOpacity; +in vec3 vWorldPos; in flat uint vBaseTextureIndex; in flat uint vBaseTextureLayer; in flat uint vBatchFlags; @@ -11,6 +12,42 @@ in flat uint vDetailCategory; uniform uint uTextureIndexA; // category detail texture, layer 0 +// SceneLighting UBO — IDENTICAL layout to mesh_modern.frag binding=1 (same +// std140 block, same struct, same binding). Declared here ONLY for fog +// (uFogParams/uFogColor/uCameraAndTime); mesh_detail never lights (no +// uLights[]/uCellAmbient read), it only needs applyFog below to match +// retail's fog placement exactly. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +// Copied verbatim from mesh_modern.frag — same math, not "improved". Retail's +// D3D fixed-function fog stage runs AFTER the texture-stage pipeline +// (RenderDeviceD3D's fog render state applies to the final pixel the +// blender produced, not to an individual texture stage's output), so the +// detail contribution must be fogged exactly like the base pass fogs its +// own colour, with the identical fog curve. +vec3 applyFog(vec3 lit, vec3 worldPos) { + int mode = int(uFogParams.w); + if (mode == 0) return lit; + float d = length(worldPos - uCameraAndTime.xyz); + float fogStart = uFogParams.x; + float fogEnd = uFogParams.y; + float span = max(1e-3, fogEnd - fogStart); + float fog = clamp((d - fogStart) / span, 0.0, 1.0); + return mix(lit, uFogColor.xyz, fog); +} + out vec4 FragColor; // VM2 (2026-08-22, live cdb read on the PDB-paired retail client, GUID @@ -37,6 +74,20 @@ out vec4 FragColor; // that combined alpha and lets the fixed-function blend unit do the // base*(1-a) + detail*a lerp. See // docs/research/2026-08-22-vm2-retail-detail-path-cdb.md. +// +// Review fix (post-05970306): retail's texture-stage combine above happens +// BEFORE the D3D fixed-function fog stage, not after — fog is the LAST thing +// applied to the pixel, so it applies to the lerp's result, not to detail.rgb +// alone. mesh_modern.frag already fogs the base colour before this replay +// draws over it (applyFog(rgb, vWorldPos) there), so fogging detail.rgb here +// too makes the two-draw blend collapse to retail's single-draw order: +// (1-a)*mix(base,fog,f) + a*mix(detail,fog,f) +// = (1-f)*[(1-a)*base + a*detail] + f*fog +// = mix(lerp(base,detail,a), fog, f) +// which is exactly retail's fog-after-combine pixel. Leaving detail.rgb +// unfogged would draw detail at full saturation/brightness even at maximum +// fog distance. RetailDetailTextureContractTests pins this identity +// numerically (ExpectedFogged). void main() { // Object command replays may contain ordinary instances; only building @@ -56,5 +107,5 @@ void main() { uTextureIndexA, vec3(vDetailUv, 0.0)); - FragColor = vec4(detail.rgb, detail.a * vDetailOpacity); + FragColor = vec4(applyFog(detail.rgb, vWorldPos), detail.a * vDetailOpacity); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert index ad43ece2..9c8b8dad 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_detail.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_detail.vert @@ -70,6 +70,8 @@ uniform float uParamB; // 1 = require building instance, 0 = EnvCell category out vec2 vBaseUv; out vec2 vDetailUv; out float vDetailOpacity; +out vec3 vWorldPos; // review fix: mesh_detail.frag needs this for applyFog, + // exactly like mesh_modern.vert's vWorldPos. out flat uint vBaseTextureIndex; out flat uint vBaseTextureLayer; out flat uint vBatchFlags; @@ -80,6 +82,7 @@ void main() { int instanceIndex = transformIndex - int(uTextureIndexB); vec4 worldPos = Instances[transformIndex].transform * vec4(aPosition, 1.0); gl_Position = uViewProjection * worldPos; + vWorldPos = worldPos.xyz; uint slot = instanceClipSlot[instanceIndex]; CellClip clip = clipRegions[slot]; diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_detail.frag.spv index 95f1748af55dabb42f7f5117197456eba3056b2a..caf4e76d995042afe0417c4bafb8de18be87d4dc 100644 GIT binary patch literal 3448 zcmZ9N{cG1%6vyxOotn68K1?@P~y*}T2)};&Yopavj+;h%7_kO;swXR-}=C`Cp z>CE(NYOW=zHMO8B-(B96$Fe+;Wvw%@XMPnoJUTu)uzSzefeS7?-wcaWtx9B$v&hnh z6x(l`oYK(+#=-Sq0&D|2z%H;CJPl^REI0x>$mjkAU?j~;OOdYCO)h85S^G+h);jY#1DfD~i z^tS5G+~YFN`-^<)`sQU{18Ln6DyX~kIlG1i%QMB@H6&Q&+#A?2_x0$p*SyAEUT`t@ zk%IGV#*G%7J;;p}ob!{ry5Q_h?z)0IBXc()J^y}W%SG&PG^USo;a&LeK!#(%uW?V$Rd(eyhemnSC*5d-Z=tOZLBrtO5HmpY}}V-2W1~ zv3@K1wP!P@|COxUpMGt>L-!2Qg){S5AM%>atwp{?jNG-E z|D6o}rnvsXC%*;AnO8mX-hl4=IYnOU+z8}+!%@rMB6(~5$^OWr{EZV^`(|KI_H4X0 z?BiCX%RY>`1<2WldRu2pl`}YH^mZ4z?`=HKU=m$!166!`{tuY|^6v2+`oBb8+jqAU z_~s(#edxZqo%p?r-9YY2a%%4Z?r{ZoKf0WEHAa+o`~WakpE%nB9JCxtN{*OD2GP z%Mn5B6f1~>rj{)bS+`zr`Am0OBpc||L=J8&S1ASB2{&u{dxp?CzGFL}h>qDf= z`_T6Rkh7k;-+}#T%RAS8vONqv+;! n1@}6-oaYGcjm)(o$LPlQ^Cmb7jMs1eOXy$*sqY^$Tm=3Fw0{gb literal 2204 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 + new(random.NextSingle(), random.NextSingle(), random.NextSingle()); } diff --git a/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs new file mode 100644 index 00000000..ca048f71 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/TerrainAtlasDetailTextureTests.cs @@ -0,0 +1,173 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Wb; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Campaign VM VM1 review fix: pins the two facts the #226 note relies on +/// for retail's mip-driven attenuation to actually be true — TerrainAtlas +/// .TryCreateDetailTexture (~TerrainAtlas.cs:488-556) must upload a FULL +/// mip chain, and it must be sampled with the repeat/linear world sampler, +/// or the "attenuation is the sampler's linear mip chain" claim in +/// mesh_detail.vert's header comment and the VM1 commit is unverified. +/// Drives the private method directly (reflection, same pattern as +/// EnvCellRendererTests' private-method tests) against a synthetic +/// PFID_A8R8G8B8 RenderSurface, so no installed DAT is required — this lane +/// stays hermetic. +/// +public sealed class TerrainAtlasDetailTextureTests +{ + private const uint SurfaceTextureId = 0x05001787u; + private const uint RenderSurfaceId = 0x06006D58u; + + [Fact] + public void DetailTexture_UploadsFullMipChainAndUsesRepeatLinearSampler() + { + const int width = 4; + const int height = 4; + + using var device = new RecordingGpuDevice(); + device.Clear(); + + var dats = new FakeDetailTextureDats(); + dats.Register(new SurfaceTexture + { + Textures = new List> { RenderSurfaceId }, + }, SurfaceTextureId); + dats.Register(new RenderSurface + { + Width = width, + Height = height, + Format = PixelFormat.PFID_A8R8G8B8, + SourceData = new byte[width * height * 4], + }, RenderSurfaceId); + + var terrain = new TMTerrainDesc + { + TerrainTex = new TerrainTex + { + DetailTextureId = SurfaceTextureId, + DetailTexTiling = 4u, + }, + }; + + IGpuSampler sampler = device.CreateSampler(GpuSamplerDescription.WorldRepeat); + + MethodInfo method = typeof(TerrainAtlas).GetMethod( + "TryCreateDetailTexture", + BindingFlags.NonPublic | BindingFlags.Static)!; + object? result = method.Invoke( + null, + new object[] { device, dats, sampler, terrain, "building" }); + + Assert.NotNull(result); + + // Full mip chain: TerrainAtlas.cs sizes MipLevelCount from + // RhiWorldTextureArray.MipLevelsFor(decoded.Width, decoded.Height) and + // then calls GenerateMipChain() — not a single-level upload. + RecordingGpuTexture texture = Assert.Single(device.CreatedTextures); + Assert.Equal(width, texture.Width); + Assert.Equal(height, texture.Height); + int expectedMipLevels = RhiWorldTextureArray.MipLevelsFor(width, height); + Assert.True(expectedMipLevels > 1, "the test fixture must exercise a real mip chain, not a 1x1 edge case"); + Assert.Equal(expectedMipLevels, texture.MipLevelCount); + Assert.True(texture.MipChainGenerated); + + // Repeat/linear sampler: the exact sampler GpuBindingModel world + // draws use, not WorldClamp (the alpha atlas' sampler) or any + // point-filtered UI sampler. + GpuRecordedTextureRegistration registration = Assert.Single( + device.Calls.OfType()); + Assert.Equal(GpuSamplerDescription.WorldRepeat, registration.Sampler); + Assert.Equal(GpuFilter.Linear, registration.Sampler.MinFilter); + Assert.Equal(GpuFilter.Linear, registration.Sampler.MagFilter); + Assert.Equal(GpuMipFilter.Linear, registration.Sampler.MipFilter); + Assert.Equal(GpuAddressMode.Repeat, registration.Sampler.AddressU); + Assert.Equal(GpuAddressMode.Repeat, registration.Sampler.AddressV); + } + + /// + /// Minimal synthetic — same shape as the + /// NoopDatReaderWriter pattern already used for hermetic tests + /// (LiveEntityNetworkOnPositionCollapseMatrixTests), except Get + /// resolves from an explicit id->object map instead of always missing. + /// Every other member is unreachable by TryCreateDetailTexture and throws + /// if that assumption ever changes. + /// + private sealed class FakeDetailTextureDats : IDatReaderWriter + { + private readonly Dictionary _objects = new(); + + public void Register(T obj, uint id) where T : IDBObj => _objects[id] = obj; + + public string SourceDirectory => string.Empty; + public IDatDatabase Portal => throw new NotSupportedException(); + public IDatDatabase Cell => throw new NotSupportedException(); + public ReadOnlyDictionary CellRegions { get; } = + new(new Dictionary()); + public IDatDatabase HighRes => throw new NotSupportedException(); + public IDatDatabase Language => throw new NotSupportedException(); + public IDatDatabase Local => throw new NotSupportedException(); + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; + + public bool TryGetFileBytes( + uint regionId, + uint fileId, + ref byte[] bytes, + out int bytesRead) + { + bytesRead = 0; + return false; + } + + public IEnumerable GetAllIdsOfType() where T : IDBObj => + Array.Empty(); + + public IEnumerable ResolveId(uint id) => + Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => + _objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed ? typed : default; + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + if (_objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed) + { + value = typed; + return true; + } + value = default; + return false; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index e6ae4cbc..4ae2c907 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -116,8 +116,11 @@ public class EnvCellRendererTests /// pass, mesh_modern's shell pipeline and (when enabled) mesh_detail's /// replay alike. /// - [Fact] - public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass() + [Theory] + [InlineData(WbRenderPass.Opaque)] + [InlineData(WbRenderPass.Transparent)] + public void SubmitRhi_BindsConstantOneInstanceAlphaBeforeAnyDrawInThePass( + WbRenderPass renderPass) { const int instanceCount = 5; @@ -144,7 +147,13 @@ public class EnvCellRendererTests // Seed one real draw command, exactly what // RenderModernMDIInternal would have built from a live landblock, // so the test can assert an actual ordering against a real draw - // rather than a vacuous "no draw happened" pass. + // rather than a vacuous "no draw happened" pass. Same command for + // both passes: with _buildingDetailEnabled left at its default + // (false), the Transparent branch's own detail-interleave logic + // (SubmitRhi's "detailEnabled" block) never fires, so it draws + // through the identical MultiDrawIndexedIndirect call the Opaque + // pass does — only the pipeline rebound inside the per-range loop + // differs, which is not what this test is pinning. Type rendererType = typeof(EnvCellRenderer); FieldInfo commandsField = rendererType.GetField( "_commands", BindingFlags.NonPublic | BindingFlags.Instance)!; @@ -184,7 +193,7 @@ public class EnvCellRendererTests "SubmitRhi", BindingFlags.NonPublic | BindingFlags.Instance)!; submitRhi.Invoke( renderer, - new object[] { allInstances, WbRenderPass.Opaque, 1, instanceCount }); + new object[] { allInstances, renderPass, 1, instanceCount }); IReadOnlyList calls = device.Calls; int alphaBindIndex = -1;