From 8e138d049b683a533d03936ea6094771dbd6f96a Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 17 Sep 2026 13:34:02 -0700 Subject: [PATCH 1/5] Honor instance counts without per-instance vertex attributes Tell bgfx the explicit count when a draw derives its instance data from gl_InstanceID instead of an instance buffer. Preserve the existing buffered-instance path and gate the call on BGFX_CAPS_VERTEX_ID. Add indexed/non-indexed GPU controls for ordinary draws and forced counts of one and three, using distinct colors for all three instances. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- .../JavaScript/dist/tests.javaScript.all.js | 95 +++++++++++++++++++ .../JavaScript/src/tests.javaScript.all.ts | 3 + ...ts.nativeEngine.attributeLessInstancing.ts | 76 +++++++++++++++ Apps/UnitTests/Source/Tests.JavaScript.cpp | 5 + Plugins/NativeEngine/Source/VertexArray.cpp | 5 + 5 files changed, 184 insertions(+) create mode 100644 Apps/UnitTests/JavaScript/src/tests.nativeEngine.attributeLessInstancing.ts diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index a5d9cda5bb..50efab0117 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -1,6 +1,99 @@ /******/ (() => { // webpackBootstrap /******/ var __webpack_modules__ = ({ +/***/ "./src/tests.nativeEngine.attributeLessInstancing.ts" +/*!***********************************************************!*\ + !*** ./src/tests.nativeEngine.attributeLessInstancing.ts ***! + \***********************************************************/ +(__unused_webpack_module, __webpack_exports__, __webpack_require__) { + +"use strict"; +__webpack_require__.r(__webpack_exports__); +/* harmony export */ __webpack_require__.d(__webpack_exports__, { +/* harmony export */ registerAttributeLessInstancingTests: () => (/* binding */ registerAttributeLessInstancingTests) +/* harmony export */ }); +/* harmony import */ var _babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @babel/runtime/helpers/asyncToGenerator */ "../../node_modules/@babel/runtime/helpers/esm/asyncToGenerator.js"); +/* harmony import */ var _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! @babel/runtime/regenerator */ "../../node_modules/@babel/runtime/regenerator/index.js"); +/* harmony import */ var _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1__); +/* harmony import */ var chai__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! chai */ "../../node_modules/chai/index.js"); +/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! @babylonjs/core */ "@babylonjs/core"); +/* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_3__); + + + + +function registerAttributeLessInstancingTests( +describe, +it, +enabled) +{ + describe("NativeEngine attribute-less instancing", function () { + (enabled ? it : it.skip)("draws every instance without an instance buffer", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().mark(function _callee() {var engine, scene, camera, material, target, _i, _arr, unIndexed, mesh, _i2, _arr2, count, pixels, expected, centers, index, offset;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().wrap(function (_context) {while (1) switch (_context.prev = _context.next) {case 0: + this.timeout(15000); + engine = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.NativeEngine(); + scene = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.Scene(engine);_context.prev = 1; + + scene.clearColor = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.Color4(0, 0, 0, 1); + camera = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.FreeCamera("camera", new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.Vector3(0, 0, -5), scene); + camera.setTarget(_babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.Vector3.Zero()); + material = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.ShaderMaterial("instances", scene, { + vertexSource: "\n precision highp float;\n attribute vec3 position;\n varying vec3 instanceColor;\n void main() {\n float offset = (float(gl_InstanceID) - 1.0) * 0.6;\n gl_Position = vec4(position.x * 0.2 + offset, position.y * 0.2, 0.5, 1.0);\n instanceColor = vec3(float(gl_InstanceID == 0), float(gl_InstanceID == 1), float(gl_InstanceID == 2));\n }", + + + + + + + + + fragmentSource: "\n precision highp float;\n varying vec3 instanceColor;\n void main() { gl_FragColor = vec4(instanceColor, 1.0); }" + + + + }, { attributes: ["position"], uniforms: [] }); + material.backFaceCulling = false; + target = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.RenderTargetTexture("instances", 64, scene, false);_i = 0, _arr = + [false, true];case 2:if (!(_i < _arr.length)) {_context.next = 11;break;}unIndexed = _arr[_i]; + mesh = _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.MeshBuilder.CreatePlane("plane", { size: 2 }, scene); + if (unIndexed) { + mesh.convertToUnIndexedMesh(); + } + mesh.material = material; + target.renderList = [mesh];_i2 = 0, _arr2 = + [0, 1, 3];case 3:if (!(_i2 < _arr2.length)) {_context.next = 9;break;}count = _arr2[_i2]; + mesh.forcedInstanceCount = count;_context.next = 4;return ( + material.forceCompilationAsync(mesh, { useInstances: count > 0 }));case 4:_context.next = 5;return ( + scene.whenReadyAsync());case 5: + target.render();_context.next = 6;return ( + target.readPixels());case 6:pixels = _context.sent;if ( + pixels instanceof Uint8Array) {_context.next = 7;break;}throw ( + new Error("Expected RGBA8 instance rendering readback"));case 7: + + expected = [ + [255, 0, 0, 255], + count === 3 ? [0, 255, 0, 255] : [0, 0, 0, 255], + count === 3 ? [0, 0, 255, 255] : [0, 0, 0, 255]]; + + centers = [13, 32, 51]; + for (index = 0; index < centers.length; ++index) { + offset = (32 * 64 + centers[index]) * 4; + (0,chai__WEBPACK_IMPORTED_MODULE_2__.expect)(Array.from(pixels.subarray(offset, offset + 4)), "unIndexed=".concat( + unIndexed, ", count=").concat(count, ", instance=").concat(index)). + to.deep.equal(expected[index]); + }case 8:_i2++;_context.next = 3;break;case 9: + + mesh.dispose();case 10:_i++;_context.next = 2;break;case 11:_context.prev = 11; + + + scene.dispose(); + engine.dispose();return _context.finish(11);case 12:case "end":return _context.stop();}}, _callee, this, [[1,, 11, 12]]);})) + + ); + }); +} + +/***/ }, + /***/ "./src/tests.nativeEngine.png.ts" /*!***************************************!*\ !*** ./src/tests.nativeEngine.png.ts ***! @@ -28521,6 +28614,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(/*! @babylonjs/materials */ "@babylonjs/core"); /* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_6__); /* harmony import */ var _tests_nativeEngine_png__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./tests.nativeEngine.png */ "./src/tests.nativeEngine.png.ts"); +/* harmony import */ var _tests_nativeEngine_attributeLessInstancing__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./tests.nativeEngine.attributeLessInstancing */ "./src/tests.nativeEngine.attributeLessInstancing.ts"); function _createForOfIteratorHelper(r, e) {var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];if (!t) {if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) {t && (r = t);var _n = 0,F = function F() {};return { s: F, n: function n() {return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] };}, e: function e(r) {throw r;}, f: F };}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");}var o,a = !0,u = !1;return { s: function s() {t = t.call(r);}, n: function n() {var r = t.next();return a = r.done, r;}, e: function e(r) {u = !0, o = r;}, f: function f() {try {a || null == t.return || t.return();} finally {if (u) throw o;}} };}function _unsupportedIterableToArray(r, a) {if (r) {if ("string" == typeof r) return _arrayLikeToArray(r, a);var t = {}.toString.call(r).slice(8, -1);return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;}}function _arrayLikeToArray(r, a) {(null == a || a > r.length) && (a = r.length);for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];return n;} @@ -28703,6 +28797,7 @@ describe("Native texture readback", function () { ); }); +(0,_tests_nativeEngine_attributeLessInstancing__WEBPACK_IMPORTED_MODULE_8__.registerAttributeLessInstancingTests)(describe, it, hasNativeRendering); describe("RequestFile", function () { this.timeout(0); diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index d861bf035d..6aa7473b4c 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -24,6 +24,7 @@ import { } from "@babylonjs/core"; import { GradientMaterial } from "@babylonjs/materials"; import { registerPngTests } from "./tests.nativeEngine.png"; +import { registerAttributeLessInstancingTests } from "./tests.nativeEngine.attributeLessInstancing"; declare var describe: typeof Mocha.describe; declare var it: typeof Mocha.it; @@ -35,12 +36,14 @@ Mocha.reporter("spec"); declare const hostPlatform: string; declare const hasGpuRendering: boolean; declare const hasNativeImageLoading: boolean; +declare const hasNativeRendering: boolean; declare const setExitCode: (code: number) => void; declare const setImageReloadTestResponse: (bytes: Uint8Array) => void; declare const skipCanvasGpuTests: boolean; declare const _native: any; registerPngTests(describe, it, hasGpuRendering && hasNativeImageLoading); +registerAttributeLessInstancingTests(describe, it, hasNativeRendering); describe("Native texture readback", function () { this.timeout(10000); diff --git a/Apps/UnitTests/JavaScript/src/tests.nativeEngine.attributeLessInstancing.ts b/Apps/UnitTests/JavaScript/src/tests.nativeEngine.attributeLessInstancing.ts new file mode 100644 index 0000000000..ca48357d42 --- /dev/null +++ b/Apps/UnitTests/JavaScript/src/tests.nativeEngine.attributeLessInstancing.ts @@ -0,0 +1,76 @@ +import * as Mocha from "mocha"; +import { expect } from "chai"; +import { + Color4, FreeCamera, MeshBuilder, NativeEngine, RenderTargetTexture, + Scene, ShaderMaterial, Vector3 +} from "@babylonjs/core"; + +export function registerAttributeLessInstancingTests( + describe: typeof Mocha.describe, + it: typeof Mocha.it, + enabled: boolean +): void { + describe("NativeEngine attribute-less instancing", function () { + (enabled ? it : it.skip)("draws every instance without an instance buffer", async function () { + this.timeout(15000); + const engine = new NativeEngine(); + const scene = new Scene(engine); + try { + scene.clearColor = new Color4(0, 0, 0, 1); + const camera = new FreeCamera("camera", new Vector3(0, 0, -5), scene); + camera.setTarget(Vector3.Zero()); + const material = new ShaderMaterial("instances", scene, { + vertexSource: ` + precision highp float; + attribute vec3 position; + varying vec3 instanceColor; + void main() { + float offset = (float(gl_InstanceID) - 1.0) * 0.6; + gl_Position = vec4(position.x * 0.2 + offset, position.y * 0.2, 0.5, 1.0); + instanceColor = vec3(float(gl_InstanceID == 0), float(gl_InstanceID == 1), float(gl_InstanceID == 2)); + }`, + fragmentSource: ` + precision highp float; + varying vec3 instanceColor; + void main() { gl_FragColor = vec4(instanceColor, 1.0); }` + }, { attributes: ["position"], uniforms: [] }); + material.backFaceCulling = false; + const target = new RenderTargetTexture("instances", 64, scene, false); + for (const unIndexed of [false, true]) { + const mesh = MeshBuilder.CreatePlane("plane", { size: 2 }, scene); + if (unIndexed) { + mesh.convertToUnIndexedMesh(); + } + mesh.material = material; + target.renderList = [mesh]; + for (const count of [0, 1, 3]) { + mesh.forcedInstanceCount = count; + await material.forceCompilationAsync(mesh, { useInstances: count > 0 }); + await scene.whenReadyAsync(); + target.render(); + const pixels = await target.readPixels(); + if (!(pixels instanceof Uint8Array)) { + throw new Error("Expected RGBA8 instance rendering readback"); + } + const expected = [ + [255, 0, 0, 255], + count === 3 ? [0, 255, 0, 255] : [0, 0, 0, 255], + count === 3 ? [0, 0, 255, 255] : [0, 0, 0, 255] + ]; + const centers = [13, 32, 51]; + for (let index = 0; index < centers.length; ++index) { + const offset = (32 * 64 + centers[index]) * 4; + expect(Array.from(pixels.subarray(offset, offset + 4)), + `unIndexed=${unIndexed}, count=${count}, instance=${index}`) + .to.deep.equal(expected[index]); + } + } + mesh.dispose(); + } + } finally { + scene.dispose(); + engine.dispose(); + } + }); + }); +} diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 2c48f6d36a..211a14f1c4 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -153,6 +153,11 @@ TEST(JavaScript, All) #else env.Global().Set("hasNativeImageLoading", false); #endif +#if defined(USE_NOOP_METAL_DEVICE) || defined(SKIP_RENDER_TESTS) + env.Global().Set("hasNativeRendering", false); +#else + env.Global().Set("hasNativeRendering", true); +#endif #ifdef USE_NOOP_METAL_DEVICE env.Global().Set("hasGpuRendering", false); #else diff --git a/Plugins/NativeEngine/Source/VertexArray.cpp b/Plugins/NativeEngine/Source/VertexArray.cpp index 92b5c5e89c..27430f6b05 100644 --- a/Plugins/NativeEngine/Source/VertexArray.cpp +++ b/Plugins/NativeEngine/Source/VertexArray.cpp @@ -140,6 +140,11 @@ namespace Babylon VertexBuffer::BuildInstanceDataBuffer(instanceDataBuffer, m_vertexBufferInstances, instanceCount, instanceDataLayout); encoder->setInstanceDataBuffer(&instanceDataBuffer); } + else if (instanceCount > 0) + { + // These draws derive per-instance data from gl_InstanceID instead of an instance buffer. + encoder->setInstanceCount(instanceCount); + } uint8_t stream = 0; for (const auto& pair : m_vertexBufferRecords) From 1731e8c90f43527fe8e5875f78597d596ecd275d Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 17 Sep 2026 15:09:42 -0700 Subject: [PATCH 2/5] Require runtime vertex ID support for the instancing regression Require BGFX_CAPS_VERTEX_ID at runtime in addition to the existing no-op Metal and disabled-render-test configuration guards. Wait for the render target's own pass readiness before its single draw, with an explicit timeout and no warmup renders. Report all three pixel centers together when a draw fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- .../JavaScript/dist/tests.javaScript.all.js | 64 ++++++++++--------- .../JavaScript/src/tests.javaScript.all.ts | 4 +- ...ts.nativeEngine.attributeLessInstancing.ts | 18 ++++-- Apps/UnitTests/Source/Tests.JavaScript.cpp | 4 +- 4 files changed, 51 insertions(+), 39 deletions(-) diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 50efab0117..342b1910f7 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28,10 +28,10 @@ it, enabled) { describe("NativeEngine attribute-less instancing", function () { - (enabled ? it : it.skip)("draws every instance without an instance buffer", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().mark(function _callee() {var engine, scene, camera, material, target, _i, _arr, unIndexed, mesh, _i2, _arr2, count, pixels, expected, centers, index, offset;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().wrap(function (_context) {while (1) switch (_context.prev = _context.next) {case 0: + (enabled ? it : it.skip)("draws every instance without an instance buffer", /*#__PURE__*/(0,_babel_runtime_helpers_asyncToGenerator__WEBPACK_IMPORTED_MODULE_0__["default"])(/*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().mark(function _callee() {var engine, scene, camera, material, target, _i, _arr, unIndexed, mesh, _loop, _i2, _arr2;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().wrap(function (_context2) {while (1) switch (_context2.prev = _context2.next) {case 0: this.timeout(15000); engine = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.NativeEngine(); - scene = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.Scene(engine);_context.prev = 1; + scene = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.Scene(engine);_context2.prev = 1; scene.clearColor = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.Color4(0, 0, 0, 1); camera = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.FreeCamera("camera", new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.Vector3(0, 0, -5), scene); @@ -53,40 +53,46 @@ enabled) }, { attributes: ["position"], uniforms: [] }); material.backFaceCulling = false; target = new _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.RenderTargetTexture("instances", 64, scene, false);_i = 0, _arr = - [false, true];case 2:if (!(_i < _arr.length)) {_context.next = 11;break;}unIndexed = _arr[_i]; + [false, true];case 2:if (!(_i < _arr.length)) {_context2.next = 7;break;}unIndexed = _arr[_i]; mesh = _babylonjs_core__WEBPACK_IMPORTED_MODULE_3__.MeshBuilder.CreatePlane("plane", { size: 2 }, scene); if (unIndexed) { mesh.convertToUnIndexedMesh(); } mesh.material = material; - target.renderList = [mesh];_i2 = 0, _arr2 = - [0, 1, 3];case 3:if (!(_i2 < _arr2.length)) {_context.next = 9;break;}count = _arr2[_i2]; - mesh.forcedInstanceCount = count;_context.next = 4;return ( - material.forceCompilationAsync(mesh, { useInstances: count > 0 }));case 4:_context.next = 5;return ( - scene.whenReadyAsync());case 5: - target.render();_context.next = 6;return ( - target.readPixels());case 6:pixels = _context.sent;if ( - pixels instanceof Uint8Array) {_context.next = 7;break;}throw ( - new Error("Expected RGBA8 instance rendering readback"));case 7: - - expected = [ - [255, 0, 0, 255], - count === 3 ? [0, 255, 0, 255] : [0, 0, 0, 255], - count === 3 ? [0, 0, 255, 255] : [0, 0, 0, 255]]; - - centers = [13, 32, 51]; - for (index = 0; index < centers.length; ++index) { - offset = (32 * 64 + centers[index]) * 4; - (0,chai__WEBPACK_IMPORTED_MODULE_2__.expect)(Array.from(pixels.subarray(offset, offset + 4)), "unIndexed=".concat( - unIndexed, ", count=").concat(count, ", instance=").concat(index)). - to.deep.equal(expected[index]); - }case 8:_i2++;_context.next = 3;break;case 9: - - mesh.dispose();case 10:_i++;_context.next = 2;break;case 11:_context.prev = 11; + target.renderList = [mesh];_loop = /*#__PURE__*/_babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().mark(function _loop() {var count, readinessDeadline, pixels, expected, centers, actual;return _babel_runtime_regenerator__WEBPACK_IMPORTED_MODULE_1___default().wrap(function (_context) {while (1) switch (_context.prev = _context.next) {case 0: + count = _arr2[_i2]; + mesh.forcedInstanceCount = count;_context.next = 1;return ( + material.forceCompilationAsync(mesh, { useInstances: count > 0 }));case 1:_context.next = 2;return ( + scene.whenReadyAsync());case 2: + readinessDeadline = Date.now() + 10000;case 3:if ( + target.isReadyForRendering()) {_context.next = 6;break;}if (!( + Date.now() >= readinessDeadline)) {_context.next = 4;break;}throw ( + new Error("Render target did not become ready: unIndexed=".concat(unIndexed, ", count=").concat(count)));case 4:_context.next = 5;return ( + + new Promise(function (resolve) {return setTimeout(resolve, 10);}));case 5:_context.next = 3;break;case 6: + + target.render();_context.next = 7;return ( + target.readPixels());case 7:pixels = _context.sent;if ( + pixels instanceof Uint8Array) {_context.next = 8;break;}throw ( + new Error("Expected RGBA8 instance rendering readback"));case 8: + + expected = [ + [255, 0, 0, 255], + count === 3 ? [0, 255, 0, 255] : [0, 0, 0, 255], + count === 3 ? [0, 0, 255, 255] : [0, 0, 0, 255]]; + + centers = [13, 32, 51]; + actual = centers.map(function (center) { + var offset = (32 * 64 + center) * 4; + return Array.from(pixels.subarray(offset, offset + 4)); + }); + (0,chai__WEBPACK_IMPORTED_MODULE_2__.expect)(actual, "unIndexed=".concat(unIndexed, ", count=").concat(count)).to.deep.equal(expected);case 9:case "end":return _context.stop();}}, _loop);});_i2 = 0, _arr2 = [0, 1, 3];case 3:if (!(_i2 < _arr2.length)) {_context2.next = 5;break;}return _context2.delegateYield(_loop(), "t0", 4);case 4:_i2++;_context2.next = 3;break;case 5: + + mesh.dispose();case 6:_i++;_context2.next = 2;break;case 7:_context2.prev = 7; scene.dispose(); - engine.dispose();return _context.finish(11);case 12:case "end":return _context.stop();}}, _callee, this, [[1,, 11, 12]]);})) + engine.dispose();return _context2.finish(7);case 8:case "end":return _context2.stop();}}, _callee, this, [[1,, 7, 8]]);})) ); }); @@ -28797,7 +28803,7 @@ describe("Native texture readback", function () { ); }); -(0,_tests_nativeEngine_attributeLessInstancing__WEBPACK_IMPORTED_MODULE_8__.registerAttributeLessInstancingTests)(describe, it, hasNativeRendering); +(0,_tests_nativeEngine_attributeLessInstancing__WEBPACK_IMPORTED_MODULE_8__.registerAttributeLessInstancingTests)(describe, it, hasAttributeLessInstancing); describe("RequestFile", function () { this.timeout(0); diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index 6aa7473b4c..fe267ba3eb 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -36,14 +36,14 @@ Mocha.reporter("spec"); declare const hostPlatform: string; declare const hasGpuRendering: boolean; declare const hasNativeImageLoading: boolean; -declare const hasNativeRendering: boolean; +declare const hasAttributeLessInstancing: boolean; declare const setExitCode: (code: number) => void; declare const setImageReloadTestResponse: (bytes: Uint8Array) => void; declare const skipCanvasGpuTests: boolean; declare const _native: any; registerPngTests(describe, it, hasGpuRendering && hasNativeImageLoading); -registerAttributeLessInstancingTests(describe, it, hasNativeRendering); +registerAttributeLessInstancingTests(describe, it, hasAttributeLessInstancing); describe("Native texture readback", function () { this.timeout(10000); diff --git a/Apps/UnitTests/JavaScript/src/tests.nativeEngine.attributeLessInstancing.ts b/Apps/UnitTests/JavaScript/src/tests.nativeEngine.attributeLessInstancing.ts index ca48357d42..d2ae4bcd72 100644 --- a/Apps/UnitTests/JavaScript/src/tests.nativeEngine.attributeLessInstancing.ts +++ b/Apps/UnitTests/JavaScript/src/tests.nativeEngine.attributeLessInstancing.ts @@ -47,6 +47,13 @@ export function registerAttributeLessInstancingTests( mesh.forcedInstanceCount = count; await material.forceCompilationAsync(mesh, { useInstances: count > 0 }); await scene.whenReadyAsync(); + const readinessDeadline = Date.now() + 10000; + while (!target.isReadyForRendering()) { + if (Date.now() >= readinessDeadline) { + throw new Error(`Render target did not become ready: unIndexed=${unIndexed}, count=${count}`); + } + await new Promise(resolve => setTimeout(resolve, 10)); + } target.render(); const pixels = await target.readPixels(); if (!(pixels instanceof Uint8Array)) { @@ -58,12 +65,11 @@ export function registerAttributeLessInstancingTests( count === 3 ? [0, 0, 255, 255] : [0, 0, 0, 255] ]; const centers = [13, 32, 51]; - for (let index = 0; index < centers.length; ++index) { - const offset = (32 * 64 + centers[index]) * 4; - expect(Array.from(pixels.subarray(offset, offset + 4)), - `unIndexed=${unIndexed}, count=${count}, instance=${index}`) - .to.deep.equal(expected[index]); - } + const actual = centers.map(center => { + const offset = (32 * 64 + center) * 4; + return Array.from(pixels.subarray(offset, offset + 4)); + }); + expect(actual, `unIndexed=${unIndexed}, count=${count}`).to.deep.equal(expected); } mesh.dispose(); } diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 211a14f1c4..53ab1f935c 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -154,9 +154,9 @@ TEST(JavaScript, All) env.Global().Set("hasNativeImageLoading", false); #endif #if defined(USE_NOOP_METAL_DEVICE) || defined(SKIP_RENDER_TESTS) - env.Global().Set("hasNativeRendering", false); + env.Global().Set("hasAttributeLessInstancing", false); #else - env.Global().Set("hasNativeRendering", true); + env.Global().Set("hasAttributeLessInstancing", true); #endif #ifdef USE_NOOP_METAL_DEVICE env.Global().Set("hasGpuRendering", false); From 2ccde8f699cabce5b790e606aee9d54707ca3937 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 17 Sep 2026 17:30:53 -0700 Subject: [PATCH 3/5] Keep private capability probing out of the installed SDK consumer Mark the installation compile fixture explicitly so its shared JavaScript test source does not include private bgfx headers. Internal UnitTests keep the runtime capability check and GPU regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- Apps/UnitTests/Source/Tests.JavaScript.cpp | 2 +- Install/Test/CMakeLists.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 53ab1f935c..b413bba6a2 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -153,7 +153,7 @@ TEST(JavaScript, All) #else env.Global().Set("hasNativeImageLoading", false); #endif -#if defined(USE_NOOP_METAL_DEVICE) || defined(SKIP_RENDER_TESTS) +#if defined(USE_NOOP_METAL_DEVICE) || defined(SKIP_RENDER_TESTS) || defined(TEST_INSTALLED_SDK) env.Global().Set("hasAttributeLessInstancing", false); #else env.Global().Set("hasAttributeLessInstancing", true); diff --git a/Install/Test/CMakeLists.txt b/Install/Test/CMakeLists.txt index c0bd87ee91..8a57a6b334 100644 --- a/Install/Test/CMakeLists.txt +++ b/Install/Test/CMakeLists.txt @@ -65,6 +65,7 @@ else() endif() add_executable(TestInstall ${SOURCES}) +target_compile_definitions(TestInstall PRIVATE TEST_INSTALLED_SDK) message(STATUS "Install include directory: ${INSTALL_DIR}/include") target_include_directories(TestInstall PRIVATE "${INSTALL_DIR}/include") From b5feca80d2af5152e57e738abc9fe4e50f655ff5 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 17 Sep 2026 17:32:03 -0700 Subject: [PATCH 4/5] Emit valid Vulkan shader interfaces for attribute-less instancing Assign linked varying locations by name while preserving explicit locations and reserving complete array/matrix ranges. Assign fragment output locations and translate GLSL vertex/instance IDs to Vulkan index built-ins. The Linux lavapipe regression failed even for an ordinary count-zero draw because the previous SPIR-V lacked varying locations and used OpenGL-only ID decorations. Keep ordinary GLSL parsing and stock dependency pins. Add direct emitted-SPIR-V regressions for reversed declaration order, one-sided explicit locations, matrix arrays, vertex-only outputs, fragment outputs, and vertex/instance index built-ins. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- Apps/UnitTests/CMakeLists.txt | 1 + .../Source/Tests.ShaderCompilation.cpp | 161 ++++++++++++++++++ .../Source/ShaderCompilerVulkan.cpp | 108 ++++++++++++ 3 files changed, 270 insertions(+) diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index da7b7b23fe..aa2f989fb4 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -152,6 +152,7 @@ if(GRAPHICS_API STREQUAL "D3D12") endif() if(GRAPHICS_API STREQUAL "Vulkan") + target_link_libraries(UnitTests PRIVATE ShaderCompilerInternal spirv-cross-core) # Vulkan packaging paths (e.g. the >16 sampler rejection) are gated on this define. # ExternalTexture / native-texture helpers are not implemented for Vulkan yet, so skip # those tests rather than failing at link or runtime. diff --git a/Apps/UnitTests/Source/Tests.ShaderCompilation.cpp b/Apps/UnitTests/Source/Tests.ShaderCompilation.cpp index f65978ea2c..f18d697b03 100644 --- a/Apps/UnitTests/Source/Tests.ShaderCompilation.cpp +++ b/Apps/UnitTests/Source/Tests.ShaderCompilation.cpp @@ -16,6 +16,13 @@ #include #include +#if defined(BABYLON_NATIVE_GRAPHICS_API_VULKAN) +#include +#include +#include +#include +#endif + using namespace std::chrono_literals; extern Babylon::Graphics::Configuration g_deviceConfig; @@ -319,6 +326,25 @@ TEST(ShaderCompilation, PartiallySharedStageSamplersCompile) #if defined(BABYLON_NATIVE_GRAPHICS_API_VULKAN) namespace { + spirv_cross::Compiler ReadVulkanShader(const std::vector& bytes) + { + // These fixtures have no uniforms: bgfx v12's code length starts at byte 22. + constexpr size_t codeOffset = 26; + if (bytes.size() < codeOffset || bytes[3] != 12 || bytes[20] != 0 || bytes[21] != 0) + { + throw std::runtime_error{"Expected a uniform-free bgfx v12 shader"}; + } + uint32_t codeSize{}; + std::memcpy(&codeSize, bytes.data() + codeOffset - sizeof(codeSize), sizeof(codeSize)); + if (codeSize % sizeof(uint32_t) != 0 || codeSize > bytes.size() - codeOffset) + { + throw std::runtime_error{"Invalid SPIR-V byte length"}; + } + std::vector words(codeSize / sizeof(uint32_t)); + std::memcpy(words.data(), bytes.data() + codeOffset, codeSize); + return spirv_cross::Compiler{std::move(words)}; + } + struct SamplerCompilationResult { bool IsReady; @@ -421,4 +447,139 @@ TEST(ShaderCompilation, VulkanRejectsMoreThanSixteenSamplers) EXPECT_FALSE(result.IsReady); EXPECT_NE(result.Error.find("Vulkan shader uses more than 16 distinct sampler textures"), std::string::npos) << result.Error; } + +TEST(ShaderCompilation, VulkanAssignsMatchingVaryingLocations) +{ + Babylon::Plugins::ShaderCompiler compiler; + for (unsigned explicitStages = 0; explicitStages < 4; ++explicitStages) + { + SCOPED_TRACE(explicitStages); + const std::string vertexLocation = (explicitStages & 1) ? "layout(location = 3) " : ""; + const std::string fragmentLocation = (explicitStages & 2) ? "layout(location = 3) " : ""; + const auto info = compiler.Compile( + "#version 310 es\nprecision highp float;\nin vec3 position;\n" + + vertexLocation + R"(out vec3 colorValue; + out vec2 extraValue; + void main() { + gl_Position = vec4(position, 1.0); + colorValue = position; + extraValue = position.xy; + })", + "#version 310 es\nprecision highp float;\nin vec2 extraValue;\n" + + fragmentLocation + R"(in vec3 colorValue; + layout(location = 0) out vec4 color; + void main() { color = vec4(colorValue.xy + extraValue, colorValue.z, 1.0); })"); + auto vertex = ReadVulkanShader(info.VertexBytes); + auto fragment = ReadVulkanShader(info.FragmentBytes); + const auto outputs = vertex.get_shader_resources().stage_outputs; + const auto inputs = fragment.get_shader_resources().stage_inputs; + ASSERT_EQ(outputs.size(), 2u); + ASSERT_EQ(inputs.size(), 2u); + EXPECT_NE(vertex.get_decoration(outputs[0].id, spv::DecorationLocation), + vertex.get_decoration(outputs[1].id, spv::DecorationLocation)); + for (const auto& output : outputs) + { + ASSERT_TRUE(vertex.has_decoration(output.id, spv::DecorationLocation)) << output.name; + bool matched{}; + for (const auto& input : inputs) + { + if (input.name == output.name) + { + ASSERT_TRUE(fragment.has_decoration(input.id, spv::DecorationLocation)) << input.name; + EXPECT_EQ(vertex.get_decoration(output.id, spv::DecorationLocation), + fragment.get_decoration(input.id, spv::DecorationLocation)); + if (explicitStages != 0 && output.name == "colorValue") + { + EXPECT_EQ(vertex.get_decoration(output.id, spv::DecorationLocation), 3u); + } + matched = true; + } + } + EXPECT_TRUE(matched) << output.name; + } + } +} + +TEST(ShaderCompilation, VulkanReservesMatrixArrayLocations) +{ + Babylon::Plugins::ShaderCompiler compiler; + const auto info = compiler.Compile( + R"(#version 310 es + precision highp float; + in vec3 position; + out mat2 matrixValue[2]; + out vec3 colorValue; + out vec2 vertexOnly; + void main() { + gl_Position = vec4(position, 1.0); + matrixValue[0] = mat2(1.0); + matrixValue[1] = mat2(2.0); + colorValue = position; + vertexOnly = position.xy; + })", + R"(#version 310 es + precision highp float; + layout(location = 3) in vec3 colorValue; + in mat2 matrixValue[2]; + out vec4 color; + void main() { color = vec4(matrixValue[0] * matrixValue[1] * colorValue.xy, colorValue.z, 1.0); })"); + auto vertex = ReadVulkanShader(info.VertexBytes); + auto fragment = ReadVulkanShader(info.FragmentBytes); + for (const auto* stage : {&vertex, &fragment}) + { + const auto resources = stage->get_shader_resources(); + const auto& varyings = stage == &vertex ? resources.stage_outputs : resources.stage_inputs; + ASSERT_EQ(varyings.size(), stage == &vertex ? 3u : 2u); + for (const auto& varying : varyings) + { + ASSERT_TRUE(stage->has_decoration(varying.id, spv::DecorationLocation)); + const auto location = stage->get_decoration(varying.id, spv::DecorationLocation); + if (varying.name == "matrixValue") + { + EXPECT_EQ(location, 4u); + } + else if (varying.name == "colorValue") + { + EXPECT_EQ(location, 3u); + } + else + { + EXPECT_EQ(varying.name, "vertexOnly"); + EXPECT_EQ(location, 0u); + } + } + } + const auto outputs = fragment.get_shader_resources().stage_outputs; + ASSERT_EQ(outputs.size(), 1u); + EXPECT_TRUE(fragment.has_decoration(outputs[0].id, spv::DecorationLocation)); + EXPECT_EQ(fragment.get_decoration(outputs[0].id, spv::DecorationLocation), 0u); + const auto inputs = vertex.get_shader_resources().stage_inputs; + ASSERT_EQ(inputs.size(), 1u); + EXPECT_EQ(vertex.get_decoration(inputs[0].id, spv::DecorationLocation), 0u); +} + +TEST(ShaderCompilation, VulkanUsesVertexAndInstanceIndexBuiltins) +{ + Babylon::Plugins::ShaderCompiler compiler; + const auto info = compiler.Compile( + R"(#version 310 es + precision highp float; + void main() { gl_Position = vec4(float(gl_VertexID), float(gl_InstanceID), 0.0, 1.0); })", + R"(#version 310 es + precision highp float; + layout(location = 0) out vec4 color; + void main() { color = vec4(1.0); })"); + auto vertex = ReadVulkanShader(info.VertexBytes); + bool vertexIndex{}; + bool instanceIndex{}; + for (const auto& input : vertex.get_shader_resources().builtin_inputs) + { + EXPECT_NE(input.builtin, spv::BuiltInVertexId); + EXPECT_NE(input.builtin, spv::BuiltInInstanceId); + vertexIndex |= input.builtin == spv::BuiltInVertexIndex; + instanceIndex |= input.builtin == spv::BuiltInInstanceIndex; + } + EXPECT_TRUE(vertexIndex); + EXPECT_TRUE(instanceIndex); +} #endif diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp index 4f519224c0..015e83d562 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp @@ -35,6 +35,100 @@ namespace // bgfx Vulkan m_bindInfo is indexed by sampler stage 0..15. constexpr unsigned kMaxBgfxTextureStages = 16; + class VulkanInterfaceLocationTraverser final : public TIntermTraverser + { + public: + void Collect(TIntermediate& intermediate, TStorageQualifier storage) + { + m_storage = storage; + intermediate.getTreeRoot()->traverse(this); + } + + void AssignLocations() + { + std::map locations; + std::set reserved; + for (const auto& [name, symbols] : m_symbols) + { + for (const auto* symbol : symbols) + { + const auto& qualifier = symbol->getQualifier(); + if (qualifier.hasLocation()) + { + locations[name] = qualifier.layoutLocation; + const auto count = TIntermediate::computeTypeLocationSize(symbol->getType(), EShLangFragment); + for (int offset = 0; offset < count; ++offset) + { + reserved.insert(qualifier.layoutLocation + offset); + } + } + } + } + for (const auto& [name, symbols] : m_symbols) + { + if (locations.find(name) == locations.end()) + { + const auto count = TIntermediate::computeTypeLocationSize(symbols.front()->getType(), EShLangFragment); + unsigned location{}; + for (int offset = 0; offset < count; ++offset) + { + if (reserved.count(location + offset)) + { + location += offset + 1; + offset = -1; + } + } + locations[name] = location; + for (int offset = 0; offset < count; ++offset) + { + reserved.insert(location + offset); + } + } + for (auto* symbol : symbols) + { + symbol->getWritableType().getQualifier().layoutLocation = locations.at(name); + } + } + } + + private: + void visitSymbol(TIntermSymbol* symbol) override + { + const auto& type = symbol->getType(); + if (type.getQualifier().storage != m_storage || type.isBuiltIn()) + { + return; + } + if (type.isStruct() && !type.getStruct()->empty() && type.getStruct()->front().type->isBuiltIn()) + { + return; + } + m_symbols[symbol->getName().c_str()].push_back(symbol); + } + + TStorageQualifier m_storage{}; + std::map> m_symbols; + }; + + class VulkanBuiltInTraverser final : public TIntermTraverser + { + void visitSymbol(TIntermSymbol* symbol) override + { + auto& qualifier = symbol->getWritableType().getQualifier(); + switch (qualifier.builtIn) + { + case EbvVertexId: + qualifier.builtIn = EbvVertexIndex; + break; + case EbvInstanceId: + qualifier.builtIn = EbvInstanceIndex; + break; + default: + break; + } + } + }; + void CollectStageUniforms( glslang::TIntermediate* intermediate, std::vector& textures, @@ -277,6 +371,20 @@ namespace Babylon::Plugins throw std::runtime_error{program.getInfoLog()}; } + // The GLSLANG_WEB build omits mapIO. Match stage interfaces by name and + // reserve explicit locations before allocating implicit ones. + VulkanInterfaceLocationTraverser varyings; + varyings.Collect(*program.getIntermediate(EShLangVertex), EvqVaryingOut); + varyings.Collect(*program.getIntermediate(EShLangFragment), EvqVaryingIn); + varyings.AssignLocations(); + VulkanInterfaceLocationTraverser outputs; + outputs.Collect(*program.getIntermediate(EShLangFragment), EvqVaryingOut); + outputs.AssignLocations(); + + // Parse Babylon's ordinary GLSL, but emit Vulkan's index built-ins. + VulkanBuiltInTraverser builtIns; + program.getIntermediate(EShLangVertex)->getTreeRoot()->traverse(&builtIns); + ShaderCompilerTraversers::IdGenerator ids{}; // Flip 2D texture sample coordinates (replaces the former ProcessSamplerFlip texture() macro). ShaderCompilerTraversers::FlipSamplerCoordinates(program); From 4a60437698eabe40c063bfcbe31ca641b515dcb7 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Thu, 17 Sep 2026 18:16:37 -0700 Subject: [PATCH 5/5] Link Vulkan interface blocks by type rather than instance name Use the linked block type name as the location key, matching glslang's getAccessName semantics while retaining ordinary varying names. Vertex and fragment stages can use different block instance names. Add a regression with different instance names, a matrix member, an ordinary varying, and an explicit location on only the fragment block. Exercise it with the existing BABYLON_NATIVE_DISABLE_WEBMIN option; the default minified parser does not support shader IO blocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- Apps/UnitTests/CMakeLists.txt | 2 +- .../Source/Tests.ShaderCompilation.cpp | 57 +++++++++++++++++++ .../Source/ShaderCompilerVulkan.cpp | 4 +- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index aa2f989fb4..685fa192c1 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -152,7 +152,7 @@ if(GRAPHICS_API STREQUAL "D3D12") endif() if(GRAPHICS_API STREQUAL "Vulkan") - target_link_libraries(UnitTests PRIVATE ShaderCompilerInternal spirv-cross-core) + target_link_libraries(UnitTests PRIVATE ShaderCompilerInternal spirv-cross-core glslang) # Vulkan packaging paths (e.g. the >16 sampler rejection) are gated on this define. # ExternalTexture / native-texture helpers are not implemented for Vulkan yet, so skip # those tests rather than failing at link or runtime. diff --git a/Apps/UnitTests/Source/Tests.ShaderCompilation.cpp b/Apps/UnitTests/Source/Tests.ShaderCompilation.cpp index f18d697b03..dcc237fff4 100644 --- a/Apps/UnitTests/Source/Tests.ShaderCompilation.cpp +++ b/Apps/UnitTests/Source/Tests.ShaderCompilation.cpp @@ -558,6 +558,63 @@ TEST(ShaderCompilation, VulkanReservesMatrixArrayLocations) EXPECT_EQ(vertex.get_decoration(inputs[0].id, spv::DecorationLocation), 0u); } +TEST(ShaderCompilation, VulkanLinksBlocksWithDifferentInstanceNames) +{ +#if defined(GLSLANG_WEB) + GTEST_SKIP() << "Interface blocks require BABYLON_NATIVE_DISABLE_WEBMIN"; +#else + Babylon::Plugins::ShaderCompiler compiler; + for (const std::string fragmentLocation : {"", "layout(location = 4) "}) + { + SCOPED_TRACE(fragmentLocation); + const auto info = compiler.Compile( + R"(#version 310 es + #extension GL_EXT_shader_io_blocks : require + precision highp float; + in vec3 position; + out Payload { mat2 transform; vec3 tint; } vertexData; + out vec2 extraValue; + void main() { + gl_Position = vec4(position, 1.0); + vertexData.transform = mat2(1.0); + vertexData.tint = position; + extraValue = position.xy; + })", + "#version 310 es\n#extension GL_EXT_shader_io_blocks : require\n" + "precision highp float;\nin vec2 extraValue;\n" + + fragmentLocation + R"(in Payload { mat2 transform; vec3 tint; } fragmentData; + layout(location = 0) out vec4 color; + void main() { color = vec4(fragmentData.transform * extraValue, fragmentData.tint.z, 1.0); })"); + auto vertex = ReadVulkanShader(info.VertexBytes); + auto fragment = ReadVulkanShader(info.FragmentBytes); + std::map vertexLocations; + std::map fragmentLocations; + for (const auto* stage : {&vertex, &fragment}) + { + const auto resources = stage->get_shader_resources(); + const auto& varyings = stage == &vertex ? resources.stage_outputs : resources.stage_inputs; + ASSERT_EQ(varyings.size(), 2u); + auto& locations = stage == &vertex ? vertexLocations : fragmentLocations; + for (const auto& varying : varyings) + { + ASSERT_TRUE(stage->has_decoration(varying.id, spv::DecorationLocation)); + locations[varying.name] = stage->get_decoration(varying.id, spv::DecorationLocation); + } + } + ASSERT_EQ(vertexLocations.count("Payload"), 1u); + ASSERT_EQ(vertexLocations.count("extraValue"), 1u); + EXPECT_EQ(vertexLocations, fragmentLocations); + const auto blockLocation = vertexLocations.at("Payload"); + const auto extraLocation = vertexLocations.at("extraValue"); + EXPECT_TRUE(extraLocation < blockLocation || extraLocation >= blockLocation + 3); + if (!fragmentLocation.empty()) + { + EXPECT_EQ(blockLocation, 4u); + } + } +#endif +} + TEST(ShaderCompilation, VulkanUsesVertexAndInstanceIndexBuiltins) { Babylon::Plugins::ShaderCompiler compiler; diff --git a/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp b/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp index 015e83d562..c925f2a950 100644 --- a/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp +++ b/Plugins/ShaderCompiler/Source/ShaderCompilerVulkan.cpp @@ -103,7 +103,9 @@ namespace { return; } - m_symbols[symbol->getName().c_str()].push_back(symbol); + // Block instance names are stage-local; the block type identifies the interface. + const auto& name = type.getBasicType() == EbtBlock ? type.getTypeName() : symbol->getName(); + m_symbols[name.c_str()].push_back(symbol); } TStorageQualifier m_storage{};