bloody-engine 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/node/index.js +2364 -0
- package/package.json +3 -2
- package/dist/node/batch-renderer-JqZ4TYcL.js +0 -308
- package/dist/node/browser-resource-loader-D51BD3k_.js +0 -146
- package/dist/node/camera-A8EGrk7U.js +0 -271
- package/dist/node/index-node.js +0 -2117
- package/dist/node/node-resource-loader-MzkD-IGo.js +0 -166
- package/dist/node/resource-loader-factory-DQ-PAVcN.js +0 -93
- package/dist/node/resource-pipeline-Dac9qRso.js +0 -211
|
@@ -0,0 +1,2364 @@
|
|
|
1
|
+
import createGL from "gl";
|
|
2
|
+
import * as fs from "fs/promises";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
class BrowserRenderingContext {
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.isBrowser = true;
|
|
7
|
+
if (!options.canvas) {
|
|
8
|
+
this.canvas = document.createElement("canvas");
|
|
9
|
+
document.body.appendChild(this.canvas);
|
|
10
|
+
} else {
|
|
11
|
+
this.canvas = options.canvas;
|
|
12
|
+
}
|
|
13
|
+
this.width = options.width;
|
|
14
|
+
this.height = options.height;
|
|
15
|
+
this.canvas.width = this.width;
|
|
16
|
+
this.canvas.height = this.height;
|
|
17
|
+
const contextAttributes = {
|
|
18
|
+
alpha: false,
|
|
19
|
+
...options.contextAttributes
|
|
20
|
+
};
|
|
21
|
+
const glContext = this.canvas.getContext("webgl", contextAttributes);
|
|
22
|
+
if (!glContext) {
|
|
23
|
+
throw new Error("Failed to initialize WebGL context in browser");
|
|
24
|
+
}
|
|
25
|
+
this.glContext = glContext;
|
|
26
|
+
}
|
|
27
|
+
resize(width, height) {
|
|
28
|
+
this.width = width;
|
|
29
|
+
this.height = height;
|
|
30
|
+
this.canvas.width = width;
|
|
31
|
+
this.canvas.height = height;
|
|
32
|
+
this.glContext.viewport(0, 0, width, height);
|
|
33
|
+
}
|
|
34
|
+
getViewport() {
|
|
35
|
+
return { width: this.width, height: this.height };
|
|
36
|
+
}
|
|
37
|
+
clear(color) {
|
|
38
|
+
if (color) {
|
|
39
|
+
this.glContext.clearColor(color.r, color.g, color.b, color.a);
|
|
40
|
+
}
|
|
41
|
+
this.glContext.clear(
|
|
42
|
+
this.glContext.COLOR_BUFFER_BIT | this.glContext.DEPTH_BUFFER_BIT
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
present() {
|
|
46
|
+
}
|
|
47
|
+
dispose() {
|
|
48
|
+
if (this.canvas.parentElement) {
|
|
49
|
+
this.canvas.parentElement.removeChild(this.canvas);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Get the underlying canvas element (browser-specific)
|
|
54
|
+
*/
|
|
55
|
+
getCanvas() {
|
|
56
|
+
return this.canvas;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
class NodeRenderingContext {
|
|
60
|
+
constructor(options) {
|
|
61
|
+
this.isBrowser = false;
|
|
62
|
+
this.width = options.width;
|
|
63
|
+
this.height = options.height;
|
|
64
|
+
const glContext = createGL(this.width, this.height, {
|
|
65
|
+
preserveDrawingBuffer: options.preserveDrawingBuffer ?? true,
|
|
66
|
+
...options.contextAttributes
|
|
67
|
+
});
|
|
68
|
+
if (!glContext) {
|
|
69
|
+
throw new Error("Failed to initialize WebGL context in Node.js");
|
|
70
|
+
}
|
|
71
|
+
this.glContext = glContext;
|
|
72
|
+
}
|
|
73
|
+
resize(width, height) {
|
|
74
|
+
this.width = width;
|
|
75
|
+
this.height = height;
|
|
76
|
+
console.warn(
|
|
77
|
+
"NodeRenderingContext: Resize requested but not supported. Consider recreating context."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
getViewport() {
|
|
81
|
+
return { width: this.width, height: this.height };
|
|
82
|
+
}
|
|
83
|
+
clear(color) {
|
|
84
|
+
if (color) {
|
|
85
|
+
this.glContext.clearColor(color.r, color.g, color.b, color.a);
|
|
86
|
+
}
|
|
87
|
+
this.glContext.clear(
|
|
88
|
+
this.glContext.COLOR_BUFFER_BIT | this.glContext.DEPTH_BUFFER_BIT
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
present() {
|
|
92
|
+
this.glContext.flush();
|
|
93
|
+
}
|
|
94
|
+
dispose() {
|
|
95
|
+
this.glContext.flush();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Read the current framebuffer contents as RGBA pixel data
|
|
99
|
+
* Used for capturing frames for display or saving
|
|
100
|
+
*/
|
|
101
|
+
readPixels() {
|
|
102
|
+
const pixelData = new Uint8Array(this.width * this.height * 4);
|
|
103
|
+
this.glContext.readPixels(
|
|
104
|
+
0,
|
|
105
|
+
0,
|
|
106
|
+
this.width,
|
|
107
|
+
this.height,
|
|
108
|
+
this.glContext.RGBA,
|
|
109
|
+
this.glContext.UNSIGNED_BYTE,
|
|
110
|
+
pixelData
|
|
111
|
+
);
|
|
112
|
+
return pixelData;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
class RenderingContextFactory {
|
|
116
|
+
/**
|
|
117
|
+
* Detect if running in a browser environment
|
|
118
|
+
*/
|
|
119
|
+
static isBrowserEnvironment() {
|
|
120
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Create a rendering context appropriate for the current environment
|
|
124
|
+
*/
|
|
125
|
+
static createContext(options) {
|
|
126
|
+
if (this.isBrowserEnvironment()) {
|
|
127
|
+
return new BrowserRenderingContext(options);
|
|
128
|
+
} else {
|
|
129
|
+
return new NodeRenderingContext(options);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Create a browser-specific rendering context
|
|
134
|
+
*/
|
|
135
|
+
static createBrowserContext(options) {
|
|
136
|
+
return new BrowserRenderingContext(options);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Create a Node.js-specific rendering context
|
|
140
|
+
*/
|
|
141
|
+
static createNodeContext(options) {
|
|
142
|
+
return new NodeRenderingContext(options);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
class Shader {
|
|
146
|
+
/**
|
|
147
|
+
* Create a new shader program
|
|
148
|
+
* @param gl WebGL rendering context
|
|
149
|
+
* @param vertexSource Raw vertex shader source code
|
|
150
|
+
* @param fragmentSource Raw fragment shader source code
|
|
151
|
+
* @param isBrowser Whether running in browser environment (affects precision header)
|
|
152
|
+
*/
|
|
153
|
+
constructor(gl, vertexSource, fragmentSource, isBrowser) {
|
|
154
|
+
this.gl = gl;
|
|
155
|
+
const processedVertexSource = this.injectPrecisionHeader(
|
|
156
|
+
vertexSource,
|
|
157
|
+
isBrowser
|
|
158
|
+
);
|
|
159
|
+
const processedFragmentSource = this.injectPrecisionHeader(
|
|
160
|
+
fragmentSource,
|
|
161
|
+
isBrowser
|
|
162
|
+
);
|
|
163
|
+
this.vertexShader = this.compileShader(
|
|
164
|
+
processedVertexSource,
|
|
165
|
+
gl.VERTEX_SHADER
|
|
166
|
+
);
|
|
167
|
+
this.fragmentShader = this.compileShader(
|
|
168
|
+
processedFragmentSource,
|
|
169
|
+
gl.FRAGMENT_SHADER
|
|
170
|
+
);
|
|
171
|
+
this.program = this.linkProgram(this.vertexShader, this.fragmentShader);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Inject precision header for ES and desktop OpenGL differences
|
|
175
|
+
* @param source Original shader source
|
|
176
|
+
* @param isBrowser Whether in browser (WebGL ES) or Node (desktop OpenGL)
|
|
177
|
+
* @returns Processed shader source with precision header
|
|
178
|
+
*/
|
|
179
|
+
injectPrecisionHeader(source, isBrowser) {
|
|
180
|
+
if (source.includes("#ifdef GL_ES") || source.includes("precision")) {
|
|
181
|
+
return source;
|
|
182
|
+
}
|
|
183
|
+
if (isBrowser) {
|
|
184
|
+
const precisionHeader = `#ifdef GL_ES
|
|
185
|
+
precision highp float;
|
|
186
|
+
#endif
|
|
187
|
+
`;
|
|
188
|
+
return precisionHeader + source;
|
|
189
|
+
} else {
|
|
190
|
+
const precisionHeader = `#ifdef GL_ES
|
|
191
|
+
precision highp float;
|
|
192
|
+
#endif
|
|
193
|
+
`;
|
|
194
|
+
return precisionHeader + source;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Compile a single shader (vertex or fragment)
|
|
199
|
+
* @param source Shader source code
|
|
200
|
+
* @param type gl.VERTEX_SHADER or gl.FRAGMENT_SHADER
|
|
201
|
+
* @returns Compiled shader
|
|
202
|
+
*/
|
|
203
|
+
compileShader(source, type) {
|
|
204
|
+
const shader = this.gl.createShader(type);
|
|
205
|
+
if (!shader) {
|
|
206
|
+
throw new Error(`Failed to create shader of type ${type}`);
|
|
207
|
+
}
|
|
208
|
+
this.gl.shaderSource(shader, source);
|
|
209
|
+
this.gl.compileShader(shader);
|
|
210
|
+
const compiled = this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS);
|
|
211
|
+
if (!compiled) {
|
|
212
|
+
const infoLog = this.gl.getShaderInfoLog(shader);
|
|
213
|
+
const shaderType = type === this.gl.VERTEX_SHADER ? "vertex" : "fragment";
|
|
214
|
+
this.gl.deleteShader(shader);
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Failed to compile ${shaderType} shader:
|
|
217
|
+
${infoLog}
|
|
218
|
+
|
|
219
|
+
Source:
|
|
220
|
+
${source}`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return shader;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Link vertex and fragment shaders into a program
|
|
227
|
+
* @param vertexShader Compiled vertex shader
|
|
228
|
+
* @param fragmentShader Compiled fragment shader
|
|
229
|
+
* @returns Linked shader program
|
|
230
|
+
*/
|
|
231
|
+
linkProgram(vertexShader, fragmentShader) {
|
|
232
|
+
const program = this.gl.createProgram();
|
|
233
|
+
if (!program) {
|
|
234
|
+
throw new Error("Failed to create shader program");
|
|
235
|
+
}
|
|
236
|
+
this.gl.attachShader(program, vertexShader);
|
|
237
|
+
this.gl.attachShader(program, fragmentShader);
|
|
238
|
+
this.gl.linkProgram(program);
|
|
239
|
+
const linked = this.gl.getProgramParameter(program, this.gl.LINK_STATUS);
|
|
240
|
+
if (!linked) {
|
|
241
|
+
const infoLog = this.gl.getProgramInfoLog(program);
|
|
242
|
+
this.gl.deleteProgram(program);
|
|
243
|
+
this.gl.deleteShader(vertexShader);
|
|
244
|
+
this.gl.deleteShader(fragmentShader);
|
|
245
|
+
throw new Error(`Failed to link shader program:
|
|
246
|
+
${infoLog}`);
|
|
247
|
+
}
|
|
248
|
+
return program;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Get the compiled shader program
|
|
252
|
+
*/
|
|
253
|
+
getProgram() {
|
|
254
|
+
return this.program;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Get uniform location by name
|
|
258
|
+
* @param name Uniform variable name
|
|
259
|
+
*/
|
|
260
|
+
getUniformLocation(name) {
|
|
261
|
+
return this.gl.getUniformLocation(this.program, name);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Get attribute location by name
|
|
265
|
+
* @param name Attribute variable name
|
|
266
|
+
*/
|
|
267
|
+
getAttributeLocation(name) {
|
|
268
|
+
return this.gl.getAttribLocation(this.program, name);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Use this shader program
|
|
272
|
+
*/
|
|
273
|
+
use() {
|
|
274
|
+
this.gl.useProgram(this.program);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Clean up shader resources
|
|
278
|
+
*/
|
|
279
|
+
dispose() {
|
|
280
|
+
this.gl.deleteProgram(this.program);
|
|
281
|
+
this.gl.deleteShader(this.vertexShader);
|
|
282
|
+
this.gl.deleteShader(this.fragmentShader);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
class GraphicsDevice {
|
|
286
|
+
constructor(width, height) {
|
|
287
|
+
this.context = RenderingContextFactory.createContext({
|
|
288
|
+
width,
|
|
289
|
+
height,
|
|
290
|
+
preserveDrawingBuffer: true
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Get the underlying WebGL rendering context
|
|
295
|
+
*/
|
|
296
|
+
getGLContext() {
|
|
297
|
+
return this.context.glContext;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Get the rendering context
|
|
301
|
+
*/
|
|
302
|
+
getRenderingContext() {
|
|
303
|
+
return this.context;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Get current width
|
|
307
|
+
*/
|
|
308
|
+
getWidth() {
|
|
309
|
+
return this.context.width;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Get current height
|
|
313
|
+
*/
|
|
314
|
+
getHeight() {
|
|
315
|
+
return this.context.height;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Get viewport dimensions
|
|
319
|
+
*/
|
|
320
|
+
getViewport() {
|
|
321
|
+
return this.context.getViewport();
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Check if running in browser
|
|
325
|
+
*/
|
|
326
|
+
isBrowser() {
|
|
327
|
+
return this.context.isBrowser;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Resize the graphics device
|
|
331
|
+
*/
|
|
332
|
+
resize(width, height) {
|
|
333
|
+
this.context.resize(width, height);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Clear the rendering surface
|
|
337
|
+
*/
|
|
338
|
+
clear(color) {
|
|
339
|
+
this.context.clear(color);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Present the rendered frame
|
|
343
|
+
*/
|
|
344
|
+
present() {
|
|
345
|
+
this.context.present();
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Cleanup and release resources
|
|
349
|
+
*/
|
|
350
|
+
dispose() {
|
|
351
|
+
this.context.dispose();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Create a shader program
|
|
355
|
+
* @param vertexSource Vertex shader source code
|
|
356
|
+
* @param fragmentSource Fragment shader source code
|
|
357
|
+
* @returns Compiled and linked shader program
|
|
358
|
+
*/
|
|
359
|
+
createShader(vertexSource, fragmentSource) {
|
|
360
|
+
return new Shader(
|
|
361
|
+
this.context.glContext,
|
|
362
|
+
vertexSource,
|
|
363
|
+
fragmentSource,
|
|
364
|
+
this.context.isBrowser
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
class Texture {
|
|
369
|
+
/**
|
|
370
|
+
* Create a texture from pixel data
|
|
371
|
+
* @param gl WebGL context
|
|
372
|
+
* @param width Texture width
|
|
373
|
+
* @param height Texture height
|
|
374
|
+
* @param data Pixel data (Uint8Array RGBA)
|
|
375
|
+
*/
|
|
376
|
+
constructor(gl, width, height, data) {
|
|
377
|
+
this.gl = gl;
|
|
378
|
+
this.width = width;
|
|
379
|
+
this.height = height;
|
|
380
|
+
const tex = gl.createTexture();
|
|
381
|
+
if (!tex) {
|
|
382
|
+
throw new Error("Failed to create texture");
|
|
383
|
+
}
|
|
384
|
+
this.texture = tex;
|
|
385
|
+
gl.bindTexture(gl.TEXTURE_2D, this.texture);
|
|
386
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
387
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
388
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
389
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
390
|
+
if (data) {
|
|
391
|
+
gl.texImage2D(
|
|
392
|
+
gl.TEXTURE_2D,
|
|
393
|
+
0,
|
|
394
|
+
gl.RGBA,
|
|
395
|
+
width,
|
|
396
|
+
height,
|
|
397
|
+
0,
|
|
398
|
+
gl.RGBA,
|
|
399
|
+
gl.UNSIGNED_BYTE,
|
|
400
|
+
data
|
|
401
|
+
);
|
|
402
|
+
} else {
|
|
403
|
+
gl.texImage2D(
|
|
404
|
+
gl.TEXTURE_2D,
|
|
405
|
+
0,
|
|
406
|
+
gl.RGBA,
|
|
407
|
+
width,
|
|
408
|
+
height,
|
|
409
|
+
0,
|
|
410
|
+
gl.RGBA,
|
|
411
|
+
gl.UNSIGNED_BYTE,
|
|
412
|
+
null
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Create a solid color texture
|
|
419
|
+
* @param gl WebGL context
|
|
420
|
+
* @param width Texture width
|
|
421
|
+
* @param height Texture height
|
|
422
|
+
* @param r Red (0-255)
|
|
423
|
+
* @param g Green (0-255)
|
|
424
|
+
* @param b Blue (0-255)
|
|
425
|
+
* @param a Alpha (0-255)
|
|
426
|
+
*/
|
|
427
|
+
static createSolid(gl, width, height, r, g, b, a = 255) {
|
|
428
|
+
const pixelCount = width * height;
|
|
429
|
+
const data = new Uint8Array(pixelCount * 4);
|
|
430
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
431
|
+
const offset = i * 4;
|
|
432
|
+
data[offset] = r;
|
|
433
|
+
data[offset + 1] = g;
|
|
434
|
+
data[offset + 2] = b;
|
|
435
|
+
data[offset + 3] = a;
|
|
436
|
+
}
|
|
437
|
+
return new Texture(gl, width, height, data);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Create a checkerboard texture
|
|
441
|
+
* @param gl WebGL context
|
|
442
|
+
* @param width Texture width
|
|
443
|
+
* @param height Texture height
|
|
444
|
+
* @param squareSize Size of each square
|
|
445
|
+
*/
|
|
446
|
+
static createCheckerboard(gl, width, height, squareSize = 32) {
|
|
447
|
+
const data = new Uint8Array(width * height * 4);
|
|
448
|
+
for (let y = 0; y < height; y++) {
|
|
449
|
+
for (let x = 0; x < width; x++) {
|
|
450
|
+
const squareX = Math.floor(x / squareSize);
|
|
451
|
+
const squareY = Math.floor(y / squareSize);
|
|
452
|
+
const isWhite = (squareX + squareY) % 2 === 0;
|
|
453
|
+
const offset = (y * width + x) * 4;
|
|
454
|
+
const color = isWhite ? 255 : 0;
|
|
455
|
+
data[offset] = color;
|
|
456
|
+
data[offset + 1] = color;
|
|
457
|
+
data[offset + 2] = color;
|
|
458
|
+
data[offset + 3] = 255;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return new Texture(gl, width, height, data);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Create a gradient texture
|
|
465
|
+
* @param gl WebGL context
|
|
466
|
+
* @param width Texture width
|
|
467
|
+
* @param height Texture height
|
|
468
|
+
*/
|
|
469
|
+
static createGradient(gl, width, height) {
|
|
470
|
+
const data = new Uint8Array(width * height * 4);
|
|
471
|
+
for (let y = 0; y < height; y++) {
|
|
472
|
+
for (let x = 0; x < width; x++) {
|
|
473
|
+
const offset = (y * width + x) * 4;
|
|
474
|
+
data[offset] = Math.floor(x / width * 255);
|
|
475
|
+
data[offset + 1] = Math.floor(y / height * 255);
|
|
476
|
+
data[offset + 2] = 128;
|
|
477
|
+
data[offset + 3] = 255;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return new Texture(gl, width, height, data);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Bind this texture to a texture unit
|
|
484
|
+
* @param unit Texture unit (0-7 typically)
|
|
485
|
+
*/
|
|
486
|
+
bind(unit = 0) {
|
|
487
|
+
this.gl.activeTexture(this.gl.TEXTURE0 + unit);
|
|
488
|
+
this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Unbind texture
|
|
492
|
+
*/
|
|
493
|
+
unbind() {
|
|
494
|
+
this.gl.bindTexture(this.gl.TEXTURE_2D, null);
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Get the underlying WebGL texture
|
|
498
|
+
*/
|
|
499
|
+
getHandle() {
|
|
500
|
+
return this.texture;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Get texture dimensions
|
|
504
|
+
*/
|
|
505
|
+
getDimensions() {
|
|
506
|
+
return { width: this.width, height: this.height };
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Clean up texture resources
|
|
510
|
+
*/
|
|
511
|
+
dispose() {
|
|
512
|
+
this.gl.deleteTexture(this.texture);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
class VertexBuffer {
|
|
516
|
+
constructor(gl, data, stride = 0) {
|
|
517
|
+
this.gl = gl;
|
|
518
|
+
this.stride = stride;
|
|
519
|
+
const floatsPerVertex = stride > 0 ? stride / 4 : 3;
|
|
520
|
+
this.vertexCount = data.length / floatsPerVertex;
|
|
521
|
+
const buf = gl.createBuffer();
|
|
522
|
+
if (!buf) {
|
|
523
|
+
throw new Error("Failed to create vertex buffer");
|
|
524
|
+
}
|
|
525
|
+
this.buffer = buf;
|
|
526
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
|
|
527
|
+
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
|
|
528
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Bind buffer for rendering
|
|
532
|
+
*/
|
|
533
|
+
bind() {
|
|
534
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.buffer);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Unbind buffer
|
|
538
|
+
*/
|
|
539
|
+
unbind() {
|
|
540
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Get vertex count
|
|
544
|
+
*/
|
|
545
|
+
getVertexCount() {
|
|
546
|
+
return this.vertexCount;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Get stride
|
|
550
|
+
*/
|
|
551
|
+
getStride() {
|
|
552
|
+
return this.stride;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Clean up resources
|
|
556
|
+
*/
|
|
557
|
+
dispose() {
|
|
558
|
+
this.gl.deleteBuffer(this.buffer);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
class IndexBuffer {
|
|
562
|
+
constructor(gl, data) {
|
|
563
|
+
this.gl = gl;
|
|
564
|
+
this.indexCount = data.length;
|
|
565
|
+
const buf = gl.createBuffer();
|
|
566
|
+
if (!buf) {
|
|
567
|
+
throw new Error("Failed to create index buffer");
|
|
568
|
+
}
|
|
569
|
+
this.buffer = buf;
|
|
570
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.buffer);
|
|
571
|
+
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, data, gl.STATIC_DRAW);
|
|
572
|
+
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Bind buffer for rendering
|
|
576
|
+
*/
|
|
577
|
+
bind() {
|
|
578
|
+
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, this.buffer);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Unbind buffer
|
|
582
|
+
*/
|
|
583
|
+
unbind() {
|
|
584
|
+
this.gl.bindBuffer(this.gl.ELEMENT_ARRAY_BUFFER, null);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Get index count
|
|
588
|
+
*/
|
|
589
|
+
getIndexCount() {
|
|
590
|
+
return this.indexCount;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Clean up resources
|
|
594
|
+
*/
|
|
595
|
+
dispose() {
|
|
596
|
+
this.gl.deleteBuffer(this.buffer);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const buffer = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
600
|
+
__proto__: null,
|
|
601
|
+
IndexBuffer,
|
|
602
|
+
VertexBuffer
|
|
603
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
604
|
+
class BatchRenderer {
|
|
605
|
+
/**
|
|
606
|
+
* Create a new batch renderer (V1)
|
|
607
|
+
* @param gl WebGL rendering context
|
|
608
|
+
* @param shader Shader program to use
|
|
609
|
+
* @param maxQuads Maximum number of quads to batch (default 1000)
|
|
610
|
+
*/
|
|
611
|
+
constructor(gl, shader, maxQuads = 1e3) {
|
|
612
|
+
this.vertexBuffer = null;
|
|
613
|
+
this.quads = [];
|
|
614
|
+
this.isDirty = false;
|
|
615
|
+
this.verticesPerQuad = 6;
|
|
616
|
+
this.floatsPerVertex = 5;
|
|
617
|
+
this.texture = null;
|
|
618
|
+
this.gl = gl;
|
|
619
|
+
this.shader = shader;
|
|
620
|
+
this.maxQuads = maxQuads;
|
|
621
|
+
const totalFloats = maxQuads * this.verticesPerQuad * this.floatsPerVertex;
|
|
622
|
+
this.vertexData = new Float32Array(totalFloats);
|
|
623
|
+
const buf = gl.createBuffer();
|
|
624
|
+
if (!buf) {
|
|
625
|
+
throw new Error("Failed to create vertex buffer");
|
|
626
|
+
}
|
|
627
|
+
this.vertexBuffer = buf;
|
|
628
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
|
|
629
|
+
gl.bufferData(gl.ARRAY_BUFFER, this.vertexData.byteLength, gl.DYNAMIC_DRAW);
|
|
630
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Set the texture for batch rendering
|
|
634
|
+
* @param texture The texture to use when rendering
|
|
635
|
+
*/
|
|
636
|
+
setTexture(texture) {
|
|
637
|
+
this.texture = texture;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Add a quad to the batch
|
|
641
|
+
* @param quad Quad instance to add
|
|
642
|
+
*/
|
|
643
|
+
addQuad(quad) {
|
|
644
|
+
if (this.quads.length >= this.maxQuads) {
|
|
645
|
+
console.warn(`Batch renderer at max capacity (${this.maxQuads})`);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
this.quads.push(quad);
|
|
649
|
+
this.isDirty = true;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Clear all quads from the batch
|
|
653
|
+
*/
|
|
654
|
+
clear() {
|
|
655
|
+
this.quads = [];
|
|
656
|
+
this.isDirty = true;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Get number of quads currently in batch
|
|
660
|
+
*/
|
|
661
|
+
getQuadCount() {
|
|
662
|
+
return this.quads.length;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Update the batch - rebuilds vertex buffer if quads changed
|
|
666
|
+
*/
|
|
667
|
+
update() {
|
|
668
|
+
if (!this.isDirty || this.quads.length === 0) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
let vertexIndex = 0;
|
|
672
|
+
for (const quad of this.quads) {
|
|
673
|
+
const vertices = this.generateQuadVertices(quad);
|
|
674
|
+
for (const vertex of vertices) {
|
|
675
|
+
this.vertexData[vertexIndex++] = vertex[0];
|
|
676
|
+
this.vertexData[vertexIndex++] = vertex[1];
|
|
677
|
+
this.vertexData[vertexIndex++] = vertex[2];
|
|
678
|
+
this.vertexData[vertexIndex++] = vertex[3];
|
|
679
|
+
this.vertexData[vertexIndex++] = vertex[4];
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (this.vertexBuffer) {
|
|
683
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
|
|
684
|
+
this.gl.bufferSubData(
|
|
685
|
+
this.gl.ARRAY_BUFFER,
|
|
686
|
+
0,
|
|
687
|
+
this.vertexData.subarray(0, vertexIndex)
|
|
688
|
+
);
|
|
689
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
|
|
690
|
+
}
|
|
691
|
+
this.isDirty = false;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Render the batch
|
|
695
|
+
* @param camera Optional camera for view transform (defaults to identity matrix)
|
|
696
|
+
*/
|
|
697
|
+
render(camera) {
|
|
698
|
+
if (this.quads.length === 0) {
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
this.update();
|
|
702
|
+
this.shader.use();
|
|
703
|
+
if (this.vertexBuffer) {
|
|
704
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
|
|
705
|
+
const posAttr = this.shader.getAttributeLocation("aPosition");
|
|
706
|
+
const texCoordAttr = this.shader.getAttributeLocation("aTexCoord");
|
|
707
|
+
if (posAttr !== -1) {
|
|
708
|
+
this.gl.enableVertexAttribArray(posAttr);
|
|
709
|
+
this.gl.vertexAttribPointer(
|
|
710
|
+
posAttr,
|
|
711
|
+
3,
|
|
712
|
+
// 3 floats (x, y, z)
|
|
713
|
+
this.gl.FLOAT,
|
|
714
|
+
false,
|
|
715
|
+
this.floatsPerVertex * 4,
|
|
716
|
+
// stride
|
|
717
|
+
0
|
|
718
|
+
// offset
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
if (texCoordAttr !== -1) {
|
|
722
|
+
this.gl.enableVertexAttribArray(texCoordAttr);
|
|
723
|
+
this.gl.vertexAttribPointer(
|
|
724
|
+
texCoordAttr,
|
|
725
|
+
2,
|
|
726
|
+
// 2 floats (u, v)
|
|
727
|
+
this.gl.FLOAT,
|
|
728
|
+
false,
|
|
729
|
+
this.floatsPerVertex * 4,
|
|
730
|
+
// stride
|
|
731
|
+
3 * 4
|
|
732
|
+
// offset after position
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
if (this.texture) {
|
|
736
|
+
this.texture.bind(0);
|
|
737
|
+
const textureUniform = this.shader.getUniformLocation("uTexture");
|
|
738
|
+
if (textureUniform !== null) {
|
|
739
|
+
this.gl.uniform1i(textureUniform, 0);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const matrixUniform = this.shader.getUniformLocation("uMatrix");
|
|
743
|
+
if (matrixUniform !== null) {
|
|
744
|
+
const matrix = camera ? camera.getViewMatrix() : new Float32Array([
|
|
745
|
+
1,
|
|
746
|
+
0,
|
|
747
|
+
0,
|
|
748
|
+
0,
|
|
749
|
+
0,
|
|
750
|
+
1,
|
|
751
|
+
0,
|
|
752
|
+
0,
|
|
753
|
+
0,
|
|
754
|
+
0,
|
|
755
|
+
1,
|
|
756
|
+
0,
|
|
757
|
+
0,
|
|
758
|
+
0,
|
|
759
|
+
0,
|
|
760
|
+
1
|
|
761
|
+
]);
|
|
762
|
+
this.gl.uniformMatrix4fv(matrixUniform, false, matrix);
|
|
763
|
+
}
|
|
764
|
+
const vertexCount = this.quads.length * this.verticesPerQuad;
|
|
765
|
+
this.gl.drawArrays(this.gl.TRIANGLES, 0, vertexCount);
|
|
766
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Generate vertices for a quad with rotation applied
|
|
771
|
+
* Returns 6 vertices (2 triangles)
|
|
772
|
+
* @private
|
|
773
|
+
*/
|
|
774
|
+
generateQuadVertices(quad) {
|
|
775
|
+
const { x, y, width, height, rotation } = quad;
|
|
776
|
+
const halfW = width / 2;
|
|
777
|
+
const halfH = height / 2;
|
|
778
|
+
const cos = Math.cos(rotation);
|
|
779
|
+
const sin = Math.sin(rotation);
|
|
780
|
+
const rotatePoint = (px, py) => {
|
|
781
|
+
return [px * cos - py * sin, px * sin + py * cos];
|
|
782
|
+
};
|
|
783
|
+
const corners = [
|
|
784
|
+
[-halfW, -halfH],
|
|
785
|
+
// bottom-left
|
|
786
|
+
[halfW, -halfH],
|
|
787
|
+
// bottom-right
|
|
788
|
+
[halfW, halfH],
|
|
789
|
+
// top-right
|
|
790
|
+
[halfW, halfH],
|
|
791
|
+
// top-right (duplicate)
|
|
792
|
+
[-halfW, halfH],
|
|
793
|
+
// top-left
|
|
794
|
+
[-halfW, -halfH]
|
|
795
|
+
// bottom-left (duplicate)
|
|
796
|
+
];
|
|
797
|
+
const texCoords = [
|
|
798
|
+
[0, 0],
|
|
799
|
+
// bottom-left
|
|
800
|
+
[1, 0],
|
|
801
|
+
// bottom-right
|
|
802
|
+
[1, 1],
|
|
803
|
+
// top-right
|
|
804
|
+
[1, 1],
|
|
805
|
+
// top-right
|
|
806
|
+
[0, 1],
|
|
807
|
+
// top-left
|
|
808
|
+
[0, 0]
|
|
809
|
+
// bottom-left
|
|
810
|
+
];
|
|
811
|
+
const vertices = [];
|
|
812
|
+
for (let i = 0; i < corners.length; i++) {
|
|
813
|
+
const [localX, localY] = corners[i];
|
|
814
|
+
const [rotX, rotY] = rotatePoint(localX, localY);
|
|
815
|
+
const [u, v] = texCoords[i];
|
|
816
|
+
vertices.push([x + rotX, y + rotY, 0, u, v]);
|
|
817
|
+
}
|
|
818
|
+
return vertices;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Dispose resources
|
|
822
|
+
*/
|
|
823
|
+
dispose() {
|
|
824
|
+
if (this.vertexBuffer) {
|
|
825
|
+
this.gl.deleteBuffer(this.vertexBuffer);
|
|
826
|
+
this.vertexBuffer = null;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
class SpriteBatchRenderer {
|
|
831
|
+
/**
|
|
832
|
+
* Create a new sprite batch renderer (V2)
|
|
833
|
+
* @param gl WebGL rendering context
|
|
834
|
+
* @param shader Shader program to use (should be SHADERS_V2)
|
|
835
|
+
* @param maxQuads Maximum number of quads to batch (default 1000)
|
|
836
|
+
*/
|
|
837
|
+
constructor(gl, shader, maxQuads = 1e3) {
|
|
838
|
+
this.vertexBuffer = null;
|
|
839
|
+
this.quads = [];
|
|
840
|
+
this.isDirty = false;
|
|
841
|
+
this.verticesPerQuad = 6;
|
|
842
|
+
this.floatsPerVertex = 10;
|
|
843
|
+
this.texture = null;
|
|
844
|
+
this.depthTestEnabled = true;
|
|
845
|
+
this.gl = gl;
|
|
846
|
+
this.shader = shader;
|
|
847
|
+
this.maxQuads = maxQuads;
|
|
848
|
+
const totalFloats = maxQuads * this.verticesPerQuad * this.floatsPerVertex;
|
|
849
|
+
this.vertexData = new Float32Array(totalFloats);
|
|
850
|
+
const buf = gl.createBuffer();
|
|
851
|
+
if (!buf) {
|
|
852
|
+
throw new Error("Failed to create vertex buffer");
|
|
853
|
+
}
|
|
854
|
+
this.vertexBuffer = buf;
|
|
855
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
|
|
856
|
+
gl.bufferData(gl.ARRAY_BUFFER, this.vertexData.byteLength, gl.DYNAMIC_DRAW);
|
|
857
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, null);
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Set the texture for batch rendering
|
|
861
|
+
* @param texture The texture to use when rendering
|
|
862
|
+
*/
|
|
863
|
+
setTexture(texture) {
|
|
864
|
+
this.texture = texture;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Add a sprite quad to the batch
|
|
868
|
+
* @param quad Sprite quad instance to add
|
|
869
|
+
*/
|
|
870
|
+
addQuad(quad) {
|
|
871
|
+
if (this.quads.length >= this.maxQuads) {
|
|
872
|
+
console.warn(`Sprite batch renderer at max capacity (${this.maxQuads})`);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
this.quads.push(quad);
|
|
876
|
+
this.isDirty = true;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Clear all quads from the batch
|
|
880
|
+
*/
|
|
881
|
+
clear() {
|
|
882
|
+
this.quads = [];
|
|
883
|
+
this.isDirty = true;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Get number of quads currently in batch
|
|
887
|
+
*/
|
|
888
|
+
getQuadCount() {
|
|
889
|
+
return this.quads.length;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Update the batch - rebuilds vertex buffer if quads changed
|
|
893
|
+
*/
|
|
894
|
+
update() {
|
|
895
|
+
if (!this.isDirty || this.quads.length === 0) {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
let vertexIndex = 0;
|
|
899
|
+
for (const quad of this.quads) {
|
|
900
|
+
const {
|
|
901
|
+
x,
|
|
902
|
+
y,
|
|
903
|
+
z = 0,
|
|
904
|
+
width,
|
|
905
|
+
height,
|
|
906
|
+
rotation,
|
|
907
|
+
color = { r: 1, g: 1, b: 1, a: 1 },
|
|
908
|
+
uvRect = { uMin: 0, vMin: 0, uMax: 1, vMax: 1 },
|
|
909
|
+
texIndex = 0
|
|
910
|
+
} = quad;
|
|
911
|
+
const vertices = this.generateQuadVertices({
|
|
912
|
+
x,
|
|
913
|
+
y,
|
|
914
|
+
z,
|
|
915
|
+
width,
|
|
916
|
+
height,
|
|
917
|
+
rotation,
|
|
918
|
+
color,
|
|
919
|
+
uvRect,
|
|
920
|
+
texIndex
|
|
921
|
+
});
|
|
922
|
+
for (const vertex of vertices) {
|
|
923
|
+
this.vertexData[vertexIndex++] = vertex.x;
|
|
924
|
+
this.vertexData[vertexIndex++] = vertex.y;
|
|
925
|
+
this.vertexData[vertexIndex++] = vertex.z;
|
|
926
|
+
this.vertexData[vertexIndex++] = vertex.u;
|
|
927
|
+
this.vertexData[vertexIndex++] = vertex.v;
|
|
928
|
+
this.vertexData[vertexIndex++] = vertex.r;
|
|
929
|
+
this.vertexData[vertexIndex++] = vertex.g;
|
|
930
|
+
this.vertexData[vertexIndex++] = vertex.b;
|
|
931
|
+
this.vertexData[vertexIndex++] = vertex.a;
|
|
932
|
+
this.vertexData[vertexIndex++] = vertex.texIndex;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (this.vertexBuffer) {
|
|
936
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
|
|
937
|
+
this.gl.bufferSubData(
|
|
938
|
+
this.gl.ARRAY_BUFFER,
|
|
939
|
+
0,
|
|
940
|
+
this.vertexData.subarray(0, vertexIndex)
|
|
941
|
+
);
|
|
942
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
|
|
943
|
+
}
|
|
944
|
+
this.isDirty = false;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Set whether depth testing is enabled
|
|
948
|
+
* When enabled, sprites with lower Z values appear behind sprites with higher Z values
|
|
949
|
+
* @param enabled Whether to enable depth testing (default true)
|
|
950
|
+
*/
|
|
951
|
+
setDepthTestEnabled(enabled) {
|
|
952
|
+
this.depthTestEnabled = enabled;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Render the batch
|
|
956
|
+
* @param camera Optional camera for view transform (defaults to identity matrix)
|
|
957
|
+
*/
|
|
958
|
+
render(camera) {
|
|
959
|
+
if (this.quads.length === 0) {
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
this.update();
|
|
963
|
+
this.shader.use();
|
|
964
|
+
if (this.depthTestEnabled) {
|
|
965
|
+
this.gl.enable(this.gl.DEPTH_TEST);
|
|
966
|
+
this.gl.depthFunc(this.gl.LEQUAL);
|
|
967
|
+
} else {
|
|
968
|
+
this.gl.disable(this.gl.DEPTH_TEST);
|
|
969
|
+
}
|
|
970
|
+
if (this.vertexBuffer) {
|
|
971
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.vertexBuffer);
|
|
972
|
+
const posAttr = this.shader.getAttributeLocation("aPosition");
|
|
973
|
+
const texCoordAttr = this.shader.getAttributeLocation("aTexCoord");
|
|
974
|
+
const colorAttr = this.shader.getAttributeLocation("aColor");
|
|
975
|
+
const texIndexAttr = this.shader.getAttributeLocation("aTexIndex");
|
|
976
|
+
const stride = this.floatsPerVertex * 4;
|
|
977
|
+
if (posAttr !== -1) {
|
|
978
|
+
this.gl.enableVertexAttribArray(posAttr);
|
|
979
|
+
this.gl.vertexAttribPointer(
|
|
980
|
+
posAttr,
|
|
981
|
+
3,
|
|
982
|
+
// 3 floats (x, y, z)
|
|
983
|
+
this.gl.FLOAT,
|
|
984
|
+
false,
|
|
985
|
+
stride,
|
|
986
|
+
0
|
|
987
|
+
// offset
|
|
988
|
+
);
|
|
989
|
+
}
|
|
990
|
+
if (texCoordAttr !== -1) {
|
|
991
|
+
this.gl.enableVertexAttribArray(texCoordAttr);
|
|
992
|
+
this.gl.vertexAttribPointer(
|
|
993
|
+
texCoordAttr,
|
|
994
|
+
2,
|
|
995
|
+
// 2 floats (u, v)
|
|
996
|
+
this.gl.FLOAT,
|
|
997
|
+
false,
|
|
998
|
+
stride,
|
|
999
|
+
3 * 4
|
|
1000
|
+
// offset after position
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
if (colorAttr !== -1) {
|
|
1004
|
+
this.gl.enableVertexAttribArray(colorAttr);
|
|
1005
|
+
this.gl.vertexAttribPointer(
|
|
1006
|
+
colorAttr,
|
|
1007
|
+
4,
|
|
1008
|
+
// 4 floats (r, g, b, a)
|
|
1009
|
+
this.gl.FLOAT,
|
|
1010
|
+
false,
|
|
1011
|
+
stride,
|
|
1012
|
+
5 * 4
|
|
1013
|
+
// offset after texCoord
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
if (texIndexAttr !== -1) {
|
|
1017
|
+
this.gl.enableVertexAttribArray(texIndexAttr);
|
|
1018
|
+
this.gl.vertexAttribPointer(
|
|
1019
|
+
texIndexAttr,
|
|
1020
|
+
1,
|
|
1021
|
+
// 1 float (texIndex)
|
|
1022
|
+
this.gl.FLOAT,
|
|
1023
|
+
false,
|
|
1024
|
+
stride,
|
|
1025
|
+
9 * 4
|
|
1026
|
+
// offset after color
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
if (this.texture) {
|
|
1030
|
+
this.texture.bind(0);
|
|
1031
|
+
const textureUniform = this.shader.getUniformLocation("uTexture");
|
|
1032
|
+
if (textureUniform !== null) {
|
|
1033
|
+
this.gl.uniform1i(textureUniform, 0);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
const matrixUniform = this.shader.getUniformLocation("uMatrix");
|
|
1037
|
+
if (matrixUniform !== null) {
|
|
1038
|
+
const matrix = camera ? camera.getViewMatrix() : new Float32Array([
|
|
1039
|
+
1,
|
|
1040
|
+
0,
|
|
1041
|
+
0,
|
|
1042
|
+
0,
|
|
1043
|
+
0,
|
|
1044
|
+
1,
|
|
1045
|
+
0,
|
|
1046
|
+
0,
|
|
1047
|
+
0,
|
|
1048
|
+
0,
|
|
1049
|
+
1,
|
|
1050
|
+
0,
|
|
1051
|
+
0,
|
|
1052
|
+
0,
|
|
1053
|
+
0,
|
|
1054
|
+
1
|
|
1055
|
+
]);
|
|
1056
|
+
this.gl.uniformMatrix4fv(matrixUniform, false, matrix);
|
|
1057
|
+
}
|
|
1058
|
+
const vertexCount = this.quads.length * this.verticesPerQuad;
|
|
1059
|
+
this.gl.drawArrays(this.gl.TRIANGLES, 0, vertexCount);
|
|
1060
|
+
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, null);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Generate vertices for a quad with rotation applied
|
|
1065
|
+
* Returns 6 vertices (2 triangles)
|
|
1066
|
+
* @private
|
|
1067
|
+
*/
|
|
1068
|
+
generateQuadVertices(instance) {
|
|
1069
|
+
const { x, y, z, width, height, rotation, color, uvRect, texIndex } = instance;
|
|
1070
|
+
const halfW = width / 2;
|
|
1071
|
+
const halfH = height / 2;
|
|
1072
|
+
const cos = Math.cos(rotation);
|
|
1073
|
+
const sin = Math.sin(rotation);
|
|
1074
|
+
const rotatePoint = (px, py) => {
|
|
1075
|
+
return [px * cos - py * sin, px * sin + py * cos];
|
|
1076
|
+
};
|
|
1077
|
+
const corners = [
|
|
1078
|
+
[-halfW, -halfH],
|
|
1079
|
+
// bottom-left
|
|
1080
|
+
[halfW, -halfH],
|
|
1081
|
+
// bottom-right
|
|
1082
|
+
[halfW, halfH],
|
|
1083
|
+
// top-right
|
|
1084
|
+
[halfW, halfH],
|
|
1085
|
+
// top-right (duplicate)
|
|
1086
|
+
[-halfW, halfH],
|
|
1087
|
+
// top-left
|
|
1088
|
+
[-halfW, -halfH]
|
|
1089
|
+
// bottom-left (duplicate)
|
|
1090
|
+
];
|
|
1091
|
+
const texCoords = [
|
|
1092
|
+
[uvRect.uMin, uvRect.vMin],
|
|
1093
|
+
// bottom-left
|
|
1094
|
+
[uvRect.uMax, uvRect.vMin],
|
|
1095
|
+
// bottom-right
|
|
1096
|
+
[uvRect.uMax, uvRect.vMax],
|
|
1097
|
+
// top-right
|
|
1098
|
+
[uvRect.uMax, uvRect.vMax],
|
|
1099
|
+
// top-right
|
|
1100
|
+
[uvRect.uMin, uvRect.vMax],
|
|
1101
|
+
// top-left
|
|
1102
|
+
[uvRect.uMin, uvRect.vMin]
|
|
1103
|
+
// bottom-left
|
|
1104
|
+
];
|
|
1105
|
+
const vertices = [];
|
|
1106
|
+
for (let i = 0; i < corners.length; i++) {
|
|
1107
|
+
const [localX, localY] = corners[i];
|
|
1108
|
+
const [rotX, rotY] = rotatePoint(localX, localY);
|
|
1109
|
+
const [u, v] = texCoords[i];
|
|
1110
|
+
vertices.push({
|
|
1111
|
+
x: x + rotX,
|
|
1112
|
+
y: y + rotY,
|
|
1113
|
+
z,
|
|
1114
|
+
u,
|
|
1115
|
+
v,
|
|
1116
|
+
r: color.r,
|
|
1117
|
+
g: color.g,
|
|
1118
|
+
b: color.b,
|
|
1119
|
+
a: color.a,
|
|
1120
|
+
texIndex
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
return vertices;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Dispose resources
|
|
1127
|
+
*/
|
|
1128
|
+
dispose() {
|
|
1129
|
+
if (this.vertexBuffer) {
|
|
1130
|
+
this.gl.deleteBuffer(this.vertexBuffer);
|
|
1131
|
+
this.vertexBuffer = null;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
class Matrix4 {
|
|
1136
|
+
/**
|
|
1137
|
+
* Create an identity matrix
|
|
1138
|
+
* @returns 4x4 identity matrix in column-major order
|
|
1139
|
+
*/
|
|
1140
|
+
static identity() {
|
|
1141
|
+
return new Float32Array([
|
|
1142
|
+
1,
|
|
1143
|
+
0,
|
|
1144
|
+
0,
|
|
1145
|
+
0,
|
|
1146
|
+
// column 0
|
|
1147
|
+
0,
|
|
1148
|
+
1,
|
|
1149
|
+
0,
|
|
1150
|
+
0,
|
|
1151
|
+
// column 1
|
|
1152
|
+
0,
|
|
1153
|
+
0,
|
|
1154
|
+
1,
|
|
1155
|
+
0,
|
|
1156
|
+
// column 2
|
|
1157
|
+
0,
|
|
1158
|
+
0,
|
|
1159
|
+
0,
|
|
1160
|
+
1
|
|
1161
|
+
// column 3
|
|
1162
|
+
]);
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Create a translation matrix
|
|
1166
|
+
* @param x Translation along X axis
|
|
1167
|
+
* @param y Translation along Y axis
|
|
1168
|
+
* @param z Translation along Z axis (default 0)
|
|
1169
|
+
* @returns 4x4 translation matrix in column-major order
|
|
1170
|
+
*/
|
|
1171
|
+
static translation(x, y, z = 0) {
|
|
1172
|
+
return new Float32Array([
|
|
1173
|
+
1,
|
|
1174
|
+
0,
|
|
1175
|
+
0,
|
|
1176
|
+
0,
|
|
1177
|
+
// column 0
|
|
1178
|
+
0,
|
|
1179
|
+
1,
|
|
1180
|
+
0,
|
|
1181
|
+
0,
|
|
1182
|
+
// column 1
|
|
1183
|
+
0,
|
|
1184
|
+
0,
|
|
1185
|
+
1,
|
|
1186
|
+
0,
|
|
1187
|
+
// column 2
|
|
1188
|
+
x,
|
|
1189
|
+
y,
|
|
1190
|
+
z,
|
|
1191
|
+
1
|
|
1192
|
+
// column 3
|
|
1193
|
+
]);
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Create a scale matrix
|
|
1197
|
+
* @param x Scale factor along X axis
|
|
1198
|
+
* @param y Scale factor along Y axis
|
|
1199
|
+
* @param z Scale factor along Z axis (default 1)
|
|
1200
|
+
* @returns 4x4 scale matrix in column-major order
|
|
1201
|
+
*/
|
|
1202
|
+
static scale(x, y, z = 1) {
|
|
1203
|
+
return new Float32Array([
|
|
1204
|
+
x,
|
|
1205
|
+
0,
|
|
1206
|
+
0,
|
|
1207
|
+
0,
|
|
1208
|
+
// column 0
|
|
1209
|
+
0,
|
|
1210
|
+
y,
|
|
1211
|
+
0,
|
|
1212
|
+
0,
|
|
1213
|
+
// column 1
|
|
1214
|
+
0,
|
|
1215
|
+
0,
|
|
1216
|
+
z,
|
|
1217
|
+
0,
|
|
1218
|
+
// column 2
|
|
1219
|
+
0,
|
|
1220
|
+
0,
|
|
1221
|
+
0,
|
|
1222
|
+
1
|
|
1223
|
+
// column 3
|
|
1224
|
+
]);
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Multiply two matrices (result = a * b)
|
|
1228
|
+
* @param a First matrix (left operand)
|
|
1229
|
+
* @param b Second matrix (right operand)
|
|
1230
|
+
* @returns Result of matrix multiplication in column-major order
|
|
1231
|
+
*/
|
|
1232
|
+
static multiply(a, b) {
|
|
1233
|
+
const result = new Float32Array(16);
|
|
1234
|
+
for (let col = 0; col < 4; col++) {
|
|
1235
|
+
for (let row = 0; row < 4; row++) {
|
|
1236
|
+
let sum = 0;
|
|
1237
|
+
for (let k = 0; k < 4; k++) {
|
|
1238
|
+
sum += a[k * 4 + row] * b[col * 4 + k];
|
|
1239
|
+
}
|
|
1240
|
+
result[col * 4 + row] = sum;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return result;
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Create a view matrix from camera position and zoom
|
|
1247
|
+
* The view matrix transforms world coordinates to camera/eye coordinates
|
|
1248
|
+
*
|
|
1249
|
+
* View = Translation(-cameraX, -cameraY, 0) * Scale(zoom, zoom, 1)
|
|
1250
|
+
*
|
|
1251
|
+
* @param x Camera X position (translation will be negative)
|
|
1252
|
+
* @param y Camera Y position (translation will be negative)
|
|
1253
|
+
* @param zoom Camera zoom level (1.0 = no zoom, >1 = zoom in, <1 = zoom out)
|
|
1254
|
+
* @returns 4x4 view matrix in column-major order
|
|
1255
|
+
*/
|
|
1256
|
+
static createViewMatrix(x, y, zoom) {
|
|
1257
|
+
const translation = Matrix4.translation(-x, -y, 0);
|
|
1258
|
+
const scale = Matrix4.scale(zoom, zoom, 1);
|
|
1259
|
+
return Matrix4.multiply(translation, scale);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
class Camera {
|
|
1263
|
+
/**
|
|
1264
|
+
* Create a new camera
|
|
1265
|
+
* @param x Initial X position (default 0)
|
|
1266
|
+
* @param y Initial Y position (default 0)
|
|
1267
|
+
* @param zoom Initial zoom level (default 1.0)
|
|
1268
|
+
*/
|
|
1269
|
+
constructor(x = 0, y = 0, zoom = 1) {
|
|
1270
|
+
this._viewMatrix = null;
|
|
1271
|
+
this._viewMatrixDirty = true;
|
|
1272
|
+
this._x = x;
|
|
1273
|
+
this._y = y;
|
|
1274
|
+
this._zoom = zoom;
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Get the camera X position
|
|
1278
|
+
*/
|
|
1279
|
+
get x() {
|
|
1280
|
+
return this._x;
|
|
1281
|
+
}
|
|
1282
|
+
/**
|
|
1283
|
+
* Set the camera X position
|
|
1284
|
+
*/
|
|
1285
|
+
set x(value) {
|
|
1286
|
+
this._x = value;
|
|
1287
|
+
this._viewMatrixDirty = true;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Get the camera Y position
|
|
1291
|
+
*/
|
|
1292
|
+
get y() {
|
|
1293
|
+
return this._y;
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Set the camera Y position
|
|
1297
|
+
*/
|
|
1298
|
+
set y(value) {
|
|
1299
|
+
this._y = value;
|
|
1300
|
+
this._viewMatrixDirty = true;
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Get the camera zoom level
|
|
1304
|
+
*/
|
|
1305
|
+
get zoom() {
|
|
1306
|
+
return this._zoom;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Set the camera zoom level
|
|
1310
|
+
* Values: 1.0 = no zoom, >1 = zoom in, <1 = zoom out
|
|
1311
|
+
*/
|
|
1312
|
+
set zoom(value) {
|
|
1313
|
+
this._zoom = Math.max(1e-3, value);
|
|
1314
|
+
this._viewMatrixDirty = true;
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Set both X and Y position at once
|
|
1318
|
+
* @param x New X position
|
|
1319
|
+
* @param y New Y position
|
|
1320
|
+
*/
|
|
1321
|
+
setPosition(x, y) {
|
|
1322
|
+
this._x = x;
|
|
1323
|
+
this._y = y;
|
|
1324
|
+
this._viewMatrixDirty = true;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Move the camera by a relative offset
|
|
1328
|
+
* @param dx X offset to add to current position
|
|
1329
|
+
* @param dy Y offset to add to current position
|
|
1330
|
+
*/
|
|
1331
|
+
move(dx, dy) {
|
|
1332
|
+
this._x += dx;
|
|
1333
|
+
this._y += dy;
|
|
1334
|
+
this._viewMatrixDirty = true;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Scale the zoom by a factor
|
|
1338
|
+
* @param factor Multiplier for current zoom (e.g., 1.1 to zoom in 10%)
|
|
1339
|
+
*/
|
|
1340
|
+
zoomBy(factor) {
|
|
1341
|
+
this._zoom = Math.max(1e-3, this._zoom * factor);
|
|
1342
|
+
this._viewMatrixDirty = true;
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Reset camera to default position and zoom
|
|
1346
|
+
*/
|
|
1347
|
+
reset() {
|
|
1348
|
+
this._x = 0;
|
|
1349
|
+
this._y = 0;
|
|
1350
|
+
this._zoom = 1;
|
|
1351
|
+
this._viewMatrixDirty = true;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Get the view matrix for this camera
|
|
1355
|
+
* The view matrix transforms world coordinates to camera space
|
|
1356
|
+
* Caches the result until camera properties change
|
|
1357
|
+
*
|
|
1358
|
+
* @returns 4x4 view matrix in column-major order
|
|
1359
|
+
*/
|
|
1360
|
+
getViewMatrix() {
|
|
1361
|
+
if (this._viewMatrixDirty || this._viewMatrix === null) {
|
|
1362
|
+
this._viewMatrix = Matrix4.createViewMatrix(this._x, this._y, this._zoom);
|
|
1363
|
+
this._viewMatrixDirty = false;
|
|
1364
|
+
}
|
|
1365
|
+
return this._viewMatrix;
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Convert screen coordinates to world coordinates
|
|
1369
|
+
* Useful for mouse picking and interaction
|
|
1370
|
+
*
|
|
1371
|
+
* @param screenX Screen X coordinate (pixels)
|
|
1372
|
+
* @param screenY Screen Y coordinate (pixels)
|
|
1373
|
+
* @param viewportWidth Viewport width in pixels
|
|
1374
|
+
* @param viewportHeight Viewport height in pixels
|
|
1375
|
+
* @returns World coordinates {x, y}
|
|
1376
|
+
*/
|
|
1377
|
+
screenToWorld(screenX, screenY, viewportWidth, viewportHeight) {
|
|
1378
|
+
const centeredX = screenX - viewportWidth / 2;
|
|
1379
|
+
const centeredY = screenY - viewportHeight / 2;
|
|
1380
|
+
const worldX = centeredX / this._zoom + this._x;
|
|
1381
|
+
const worldY = centeredY / this._zoom + this._y;
|
|
1382
|
+
return { x: worldX, y: worldY };
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Convert world coordinates to screen coordinates
|
|
1386
|
+
* Useful for UI positioning and debug rendering
|
|
1387
|
+
*
|
|
1388
|
+
* @param worldX World X coordinate
|
|
1389
|
+
* @param worldY World Y coordinate
|
|
1390
|
+
* @param viewportWidth Viewport width in pixels
|
|
1391
|
+
* @param viewportHeight Viewport height in pixels
|
|
1392
|
+
* @returns Screen coordinates {x, y} in pixels
|
|
1393
|
+
*/
|
|
1394
|
+
worldToScreen(worldX, worldY, viewportWidth, viewportHeight) {
|
|
1395
|
+
const centeredX = (worldX - this._x) * this._zoom;
|
|
1396
|
+
const centeredY = (worldY - this._y) * this._zoom;
|
|
1397
|
+
const screenX = centeredX + viewportWidth / 2;
|
|
1398
|
+
const screenY = centeredY + viewportHeight / 2;
|
|
1399
|
+
return { x: screenX, y: screenY };
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
class BrowserResourceLoader {
|
|
1403
|
+
/**
|
|
1404
|
+
* Create a new browser resource loader
|
|
1405
|
+
* @param baseUrl Optional base URL for resolving relative paths (defaults to current origin)
|
|
1406
|
+
* @param timeout Default timeout for requests in milliseconds (default: 10000)
|
|
1407
|
+
*/
|
|
1408
|
+
constructor(baseUrl = "", timeout = 1e4) {
|
|
1409
|
+
this.baseUrl = baseUrl || this.getCurrentOrigin();
|
|
1410
|
+
this.defaultTimeout = timeout;
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Get the current origin (protocol + host + port)
|
|
1414
|
+
*/
|
|
1415
|
+
getCurrentOrigin() {
|
|
1416
|
+
return typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Resolve a relative path against the base URL
|
|
1420
|
+
* @param path Relative or absolute path
|
|
1421
|
+
* @returns Resolved absolute URL
|
|
1422
|
+
*/
|
|
1423
|
+
resolvePath(path2) {
|
|
1424
|
+
try {
|
|
1425
|
+
if (path2.startsWith("http://") || path2.startsWith("https://")) {
|
|
1426
|
+
return path2;
|
|
1427
|
+
}
|
|
1428
|
+
if (path2.startsWith("//")) {
|
|
1429
|
+
return window.location.protocol + path2;
|
|
1430
|
+
}
|
|
1431
|
+
if (path2.startsWith("/")) {
|
|
1432
|
+
return this.baseUrl + path2;
|
|
1433
|
+
}
|
|
1434
|
+
return `${this.baseUrl}/${path2}`;
|
|
1435
|
+
} catch {
|
|
1436
|
+
return path2;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Load a single resource from a URL
|
|
1441
|
+
* @param path URL or relative path to the resource
|
|
1442
|
+
* @param options Optional loading configuration
|
|
1443
|
+
* @returns Promise resolving to the resource content
|
|
1444
|
+
*/
|
|
1445
|
+
async load(path2, options) {
|
|
1446
|
+
const url = this.resolvePath(path2);
|
|
1447
|
+
try {
|
|
1448
|
+
const fetchOptions = {
|
|
1449
|
+
credentials: options?.credentials || "same-origin"
|
|
1450
|
+
};
|
|
1451
|
+
if (options?.headers) {
|
|
1452
|
+
fetchOptions.headers = options.headers;
|
|
1453
|
+
}
|
|
1454
|
+
const controller = new AbortController();
|
|
1455
|
+
const timeoutId = setTimeout(() => controller.abort(), this.defaultTimeout);
|
|
1456
|
+
fetchOptions.signal = controller.signal;
|
|
1457
|
+
const response = await fetch(url, fetchOptions);
|
|
1458
|
+
clearTimeout(timeoutId);
|
|
1459
|
+
if (!response.ok) {
|
|
1460
|
+
throw new Error(
|
|
1461
|
+
`HTTP ${response.status}: ${response.statusText} for URL: ${url}`
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
const text = await response.text();
|
|
1465
|
+
return text;
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
if (error instanceof Error) {
|
|
1468
|
+
if (error.name === "AbortError") {
|
|
1469
|
+
throw new Error(
|
|
1470
|
+
`Request timeout after ${this.defaultTimeout}ms for URL: ${url}`
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
throw new Error(`Failed to load resource from ${url}: ${error.message}`);
|
|
1474
|
+
}
|
|
1475
|
+
throw new Error(`Failed to load resource from ${url}: Unknown error`);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Load multiple resources in parallel
|
|
1480
|
+
* @param paths Array of URLs or paths
|
|
1481
|
+
* @param options Optional loading configuration
|
|
1482
|
+
* @returns Promise resolving to array of load results
|
|
1483
|
+
*/
|
|
1484
|
+
async loadMultiple(paths, options) {
|
|
1485
|
+
const promises = paths.map(async (path2) => {
|
|
1486
|
+
try {
|
|
1487
|
+
const data = await this.load(path2, options);
|
|
1488
|
+
return {
|
|
1489
|
+
data,
|
|
1490
|
+
path: path2,
|
|
1491
|
+
success: true
|
|
1492
|
+
};
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
return {
|
|
1495
|
+
data: "",
|
|
1496
|
+
path: path2,
|
|
1497
|
+
success: false,
|
|
1498
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
});
|
|
1502
|
+
return Promise.all(promises);
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Check if the path is valid for loading in the browser
|
|
1506
|
+
* @param path URL or path to check
|
|
1507
|
+
* @returns true if the path can be loaded
|
|
1508
|
+
*/
|
|
1509
|
+
canLoad(path2) {
|
|
1510
|
+
const validPatterns = [
|
|
1511
|
+
/^https?:\/\//i,
|
|
1512
|
+
// Absolute HTTP(S) URLs
|
|
1513
|
+
/^\/\//,
|
|
1514
|
+
// Protocol-relative URLs
|
|
1515
|
+
/^\//,
|
|
1516
|
+
// Absolute paths
|
|
1517
|
+
/^\.\.?\//
|
|
1518
|
+
// Relative paths starting with ./ or ../
|
|
1519
|
+
];
|
|
1520
|
+
const hasFileExtension = /\.[a-z0-9]+$/i.test(path2);
|
|
1521
|
+
return validPatterns.some((pattern) => pattern.test(path2)) || hasFileExtension;
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Set a new base URL for resolving relative paths
|
|
1525
|
+
* @param baseUrl New base URL
|
|
1526
|
+
*/
|
|
1527
|
+
setBaseUrl(baseUrl) {
|
|
1528
|
+
this.baseUrl = baseUrl;
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Get the current base URL
|
|
1532
|
+
* @returns Current base URL
|
|
1533
|
+
*/
|
|
1534
|
+
getBaseUrl() {
|
|
1535
|
+
return this.baseUrl;
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Set the default request timeout
|
|
1539
|
+
* @param timeout Timeout in milliseconds
|
|
1540
|
+
*/
|
|
1541
|
+
setTimeout(timeout) {
|
|
1542
|
+
this.defaultTimeout = timeout;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const browserResourceLoader = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1546
|
+
__proto__: null,
|
|
1547
|
+
BrowserResourceLoader
|
|
1548
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
1549
|
+
class NodeResourceLoader {
|
|
1550
|
+
/**
|
|
1551
|
+
* Create a new Node.js resource loader
|
|
1552
|
+
* @param baseDir Optional base directory for resolving relative paths (defaults to current working directory)
|
|
1553
|
+
*/
|
|
1554
|
+
constructor(baseDir = process.cwd()) {
|
|
1555
|
+
this.baseDir = baseDir;
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Resolve a relative path against the base directory
|
|
1559
|
+
* @param filePath Relative or absolute file path
|
|
1560
|
+
* @returns Resolved absolute file path
|
|
1561
|
+
*/
|
|
1562
|
+
resolvePath(filePath) {
|
|
1563
|
+
if (path.isAbsolute(filePath)) {
|
|
1564
|
+
return path.normalize(filePath);
|
|
1565
|
+
}
|
|
1566
|
+
return path.normalize(path.join(this.baseDir, filePath));
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Load a single resource from a file
|
|
1570
|
+
* @param filePath File path (relative or absolute)
|
|
1571
|
+
* @param options Optional loading configuration
|
|
1572
|
+
* @returns Promise resolving to the file content
|
|
1573
|
+
*/
|
|
1574
|
+
async load(filePath, options) {
|
|
1575
|
+
const resolvedPath = this.resolvePath(filePath);
|
|
1576
|
+
const encoding = options?.encoding || "utf-8";
|
|
1577
|
+
try {
|
|
1578
|
+
const content = await fs.readFile(resolvedPath, encoding);
|
|
1579
|
+
return content;
|
|
1580
|
+
} catch (error) {
|
|
1581
|
+
if (error instanceof Error) {
|
|
1582
|
+
const errorCode = error.code;
|
|
1583
|
+
if (errorCode === "ENOENT") {
|
|
1584
|
+
throw new Error(
|
|
1585
|
+
`File not found: ${resolvedPath} (resolved from: ${filePath})`
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
if (errorCode === "EACCES") {
|
|
1589
|
+
throw new Error(
|
|
1590
|
+
`Permission denied reading file: ${resolvedPath}`
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
if (errorCode === "EISDIR") {
|
|
1594
|
+
throw new Error(
|
|
1595
|
+
`Path is a directory, not a file: ${resolvedPath}`
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
throw new Error(
|
|
1599
|
+
`Failed to load resource from ${resolvedPath}: ${error.message}`
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
throw new Error(
|
|
1603
|
+
`Failed to load resource from ${resolvedPath}: Unknown error`
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Load multiple resources in parallel
|
|
1609
|
+
* @param filePaths Array of file paths
|
|
1610
|
+
* @param options Optional loading configuration
|
|
1611
|
+
* @returns Promise resolving to array of load results
|
|
1612
|
+
*/
|
|
1613
|
+
async loadMultiple(filePaths, options) {
|
|
1614
|
+
const promises = filePaths.map(async (filePath) => {
|
|
1615
|
+
try {
|
|
1616
|
+
const data = await this.load(filePath, options);
|
|
1617
|
+
return {
|
|
1618
|
+
data,
|
|
1619
|
+
path: filePath,
|
|
1620
|
+
success: true
|
|
1621
|
+
};
|
|
1622
|
+
} catch (error) {
|
|
1623
|
+
return {
|
|
1624
|
+
data: "",
|
|
1625
|
+
path: filePath,
|
|
1626
|
+
success: false,
|
|
1627
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
return Promise.all(promises);
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Check if the path is valid for loading in Node.js
|
|
1635
|
+
* @param filePath File path to check
|
|
1636
|
+
* @returns true if the path can be loaded
|
|
1637
|
+
*/
|
|
1638
|
+
canLoad(filePath) {
|
|
1639
|
+
const validPatterns = [
|
|
1640
|
+
/^\//,
|
|
1641
|
+
// Unix absolute paths
|
|
1642
|
+
/^[a-zA-Z]:/,
|
|
1643
|
+
// Windows absolute paths (e.g., C:\)
|
|
1644
|
+
/^\.\.?\//,
|
|
1645
|
+
// Relative paths starting with ./ or ../
|
|
1646
|
+
/^[^/\\]+\//
|
|
1647
|
+
// Relative paths without explicit prefix (e.g., "shaders/")
|
|
1648
|
+
];
|
|
1649
|
+
return validPatterns.some((pattern) => pattern.test(filePath));
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Check if a file exists without loading it
|
|
1653
|
+
* @param filePath File path to check
|
|
1654
|
+
* @returns Promise resolving to true if file exists
|
|
1655
|
+
*/
|
|
1656
|
+
async exists(filePath) {
|
|
1657
|
+
const resolvedPath = this.resolvePath(filePath);
|
|
1658
|
+
try {
|
|
1659
|
+
await fs.access(resolvedPath, fs.constants.F_OK);
|
|
1660
|
+
return true;
|
|
1661
|
+
} catch {
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Get file statistics (size, modification time, etc.)
|
|
1667
|
+
* @param filePath File path to check
|
|
1668
|
+
* @returns Promise resolving to file stats
|
|
1669
|
+
*/
|
|
1670
|
+
async getStats(filePath) {
|
|
1671
|
+
const resolvedPath = this.resolvePath(filePath);
|
|
1672
|
+
return fs.stat(resolvedPath);
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Set a new base directory for resolving relative paths
|
|
1676
|
+
* @param baseDir New base directory
|
|
1677
|
+
*/
|
|
1678
|
+
setBaseDir(baseDir) {
|
|
1679
|
+
this.baseDir = baseDir;
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Get the current base directory
|
|
1683
|
+
* @returns Current base directory
|
|
1684
|
+
*/
|
|
1685
|
+
getBaseDir() {
|
|
1686
|
+
return this.baseDir;
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* List all files in a directory
|
|
1690
|
+
* @param dirPath Directory path to list
|
|
1691
|
+
* @param recursive Whether to recursively list subdirectories (default: false)
|
|
1692
|
+
* @returns Promise resolving to array of file paths
|
|
1693
|
+
*/
|
|
1694
|
+
async listDirectory(dirPath, recursive = false) {
|
|
1695
|
+
const resolvedPath = this.resolvePath(dirPath);
|
|
1696
|
+
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
|
1697
|
+
const files = [];
|
|
1698
|
+
for (const entry of entries) {
|
|
1699
|
+
const fullPath = path.join(resolvedPath, entry.name);
|
|
1700
|
+
if (entry.isDirectory() && recursive) {
|
|
1701
|
+
const subFiles = await this.listDirectory(fullPath, true);
|
|
1702
|
+
files.push(...subFiles);
|
|
1703
|
+
} else if (entry.isFile()) {
|
|
1704
|
+
files.push(fullPath);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
return files;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
const nodeResourceLoader = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1711
|
+
__proto__: null,
|
|
1712
|
+
NodeResourceLoader
|
|
1713
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
1714
|
+
var Environment = /* @__PURE__ */ ((Environment2) => {
|
|
1715
|
+
Environment2["BROWSER"] = "browser";
|
|
1716
|
+
Environment2["NODE"] = "node";
|
|
1717
|
+
Environment2["UNKNOWN"] = "unknown";
|
|
1718
|
+
return Environment2;
|
|
1719
|
+
})(Environment || {});
|
|
1720
|
+
class ResourceLoaderFactory {
|
|
1721
|
+
/**
|
|
1722
|
+
* Detect the current runtime environment
|
|
1723
|
+
* @returns The detected environment type
|
|
1724
|
+
*/
|
|
1725
|
+
static detectEnvironment() {
|
|
1726
|
+
if (typeof window !== "undefined" && typeof window.document !== "undefined" && typeof fetch !== "undefined") {
|
|
1727
|
+
return "browser";
|
|
1728
|
+
}
|
|
1729
|
+
if (typeof process !== "undefined" && process.versions != null && process.versions.node != null) {
|
|
1730
|
+
return "node";
|
|
1731
|
+
}
|
|
1732
|
+
return "unknown";
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Check if the current environment is a browser
|
|
1736
|
+
* @returns true if running in a browser
|
|
1737
|
+
*/
|
|
1738
|
+
static isBrowser() {
|
|
1739
|
+
return this.detectEnvironment() === "browser";
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Check if the current environment is Node.js
|
|
1743
|
+
* @returns true if running in Node.js
|
|
1744
|
+
*/
|
|
1745
|
+
static isNode() {
|
|
1746
|
+
return this.detectEnvironment() === "node";
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Create a resource loader for the current environment
|
|
1750
|
+
* @param options Optional factory configuration
|
|
1751
|
+
* @returns A resource loader instance appropriate for the current platform
|
|
1752
|
+
* @throws Error if the environment is not supported
|
|
1753
|
+
*/
|
|
1754
|
+
static async create(options) {
|
|
1755
|
+
const environment = options?.forceEnvironment || this.detectEnvironment();
|
|
1756
|
+
switch (environment) {
|
|
1757
|
+
case "browser":
|
|
1758
|
+
return await this.createBrowserLoader(options);
|
|
1759
|
+
case "node":
|
|
1760
|
+
return await this.createNodeLoader(options);
|
|
1761
|
+
case "unknown":
|
|
1762
|
+
throw new Error(
|
|
1763
|
+
"Unsupported environment: Unable to determine runtime environment. Please specify forceEnvironment in options."
|
|
1764
|
+
);
|
|
1765
|
+
default:
|
|
1766
|
+
throw new Error(`Unsupported environment: ${environment}`);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* Create a browser resource loader
|
|
1771
|
+
* @param options Optional factory configuration
|
|
1772
|
+
* @returns A browser resource loader instance
|
|
1773
|
+
*/
|
|
1774
|
+
static async createBrowserLoader(options) {
|
|
1775
|
+
const { BrowserResourceLoader: Loader } = await Promise.resolve().then(() => browserResourceLoader);
|
|
1776
|
+
return new Loader(options?.baseUrl, options?.timeout);
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Create a Node.js resource loader
|
|
1780
|
+
* @param options Optional factory configuration
|
|
1781
|
+
* @returns A Node.js resource loader instance
|
|
1782
|
+
*/
|
|
1783
|
+
static async createNodeLoader(options) {
|
|
1784
|
+
const { NodeResourceLoader: Loader } = await Promise.resolve().then(() => nodeResourceLoader);
|
|
1785
|
+
return new Loader(options?.baseDir);
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Create a resource loader with automatic fallback
|
|
1789
|
+
* If the preferred loader is not available, falls back to the available loader
|
|
1790
|
+
* @param preferredEnvironment Preferred environment
|
|
1791
|
+
* @param options Optional factory configuration
|
|
1792
|
+
* @returns A resource loader instance
|
|
1793
|
+
*/
|
|
1794
|
+
static async createWithFallback(preferredEnvironment, options) {
|
|
1795
|
+
try {
|
|
1796
|
+
options = { ...options, forceEnvironment: preferredEnvironment };
|
|
1797
|
+
return await this.create(options);
|
|
1798
|
+
} catch {
|
|
1799
|
+
return await this.create({ ...options, forceEnvironment: void 0 });
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
async function createResourceLoader(options) {
|
|
1804
|
+
return await ResourceLoaderFactory.create(options);
|
|
1805
|
+
}
|
|
1806
|
+
const resourceLoaderFactory = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
|
|
1807
|
+
__proto__: null,
|
|
1808
|
+
Environment,
|
|
1809
|
+
ResourceLoaderFactory,
|
|
1810
|
+
createResourceLoader
|
|
1811
|
+
}, Symbol.toStringTag, { value: "Module" }));
|
|
1812
|
+
class ResourceCache {
|
|
1813
|
+
constructor(enabled = true) {
|
|
1814
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
1815
|
+
this.enabled = enabled;
|
|
1816
|
+
}
|
|
1817
|
+
get(key) {
|
|
1818
|
+
if (!this.enabled) return void 0;
|
|
1819
|
+
return this.cache.get(key);
|
|
1820
|
+
}
|
|
1821
|
+
set(key, value) {
|
|
1822
|
+
if (this.enabled) {
|
|
1823
|
+
this.cache.set(key, value);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
has(key) {
|
|
1827
|
+
if (!this.enabled) return false;
|
|
1828
|
+
return this.cache.has(key);
|
|
1829
|
+
}
|
|
1830
|
+
clear() {
|
|
1831
|
+
this.cache.clear();
|
|
1832
|
+
}
|
|
1833
|
+
size() {
|
|
1834
|
+
return this.cache.size;
|
|
1835
|
+
}
|
|
1836
|
+
enable() {
|
|
1837
|
+
this.enabled = true;
|
|
1838
|
+
}
|
|
1839
|
+
disable() {
|
|
1840
|
+
this.enabled = false;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
class ResourcePipeline {
|
|
1844
|
+
/**
|
|
1845
|
+
* Create a new resource loading pipeline
|
|
1846
|
+
* @param loader Resource loader instance
|
|
1847
|
+
* @param options Pipeline configuration options
|
|
1848
|
+
*/
|
|
1849
|
+
constructor(loader, options) {
|
|
1850
|
+
this.loader = loader;
|
|
1851
|
+
this.concurrency = options?.concurrency ?? 10;
|
|
1852
|
+
this.cache = new ResourceCache(options?.cache ?? true);
|
|
1853
|
+
}
|
|
1854
|
+
/**
|
|
1855
|
+
* Load a single resource with caching support
|
|
1856
|
+
* @param path Resource path or URL
|
|
1857
|
+
* @param options Optional loading options
|
|
1858
|
+
* @returns Promise resolving to the resource content
|
|
1859
|
+
*/
|
|
1860
|
+
async load(path2, options) {
|
|
1861
|
+
const cached = this.cache.get(path2);
|
|
1862
|
+
if (cached !== void 0) {
|
|
1863
|
+
return cached;
|
|
1864
|
+
}
|
|
1865
|
+
const content = await this.loader.load(path2, options);
|
|
1866
|
+
this.cache.set(path2, content);
|
|
1867
|
+
return content;
|
|
1868
|
+
}
|
|
1869
|
+
/**
|
|
1870
|
+
* Load multiple resources with concurrency control
|
|
1871
|
+
* @param paths Array of resource paths
|
|
1872
|
+
* @param options Optional loading options
|
|
1873
|
+
* @returns Promise resolving to batch load result
|
|
1874
|
+
*/
|
|
1875
|
+
async loadBatch(paths, options) {
|
|
1876
|
+
const succeeded = /* @__PURE__ */ new Map();
|
|
1877
|
+
const failed = /* @__PURE__ */ new Map();
|
|
1878
|
+
for (let i = 0; i < paths.length; i += this.concurrency) {
|
|
1879
|
+
const batch = paths.slice(i, i + this.concurrency);
|
|
1880
|
+
const results = await this.loader.loadMultiple(batch, options);
|
|
1881
|
+
for (const result of results) {
|
|
1882
|
+
if (result.success) {
|
|
1883
|
+
succeeded.set(result.path, result.data);
|
|
1884
|
+
this.cache.set(result.path, result.data);
|
|
1885
|
+
} else {
|
|
1886
|
+
failed.set(result.path, result.error || "Unknown error");
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
return {
|
|
1891
|
+
succeeded,
|
|
1892
|
+
failed,
|
|
1893
|
+
total: paths.length,
|
|
1894
|
+
successCount: succeeded.size,
|
|
1895
|
+
failureCount: failed.size
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Load a shader from separate vertex and fragment files
|
|
1900
|
+
* @param vertexPath Path to vertex shader file
|
|
1901
|
+
* @param fragmentPath Path to fragment shader file
|
|
1902
|
+
* @param options Optional loading options
|
|
1903
|
+
* @returns Promise resolving to shader source code
|
|
1904
|
+
*/
|
|
1905
|
+
async loadShader(vertexPath, fragmentPath, options) {
|
|
1906
|
+
const [vertex, fragment] = await Promise.all([
|
|
1907
|
+
this.load(vertexPath, options),
|
|
1908
|
+
this.load(fragmentPath, options)
|
|
1909
|
+
]);
|
|
1910
|
+
return { vertex, fragment };
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Load multiple shaders
|
|
1914
|
+
* @param shaders Array of shader definitions
|
|
1915
|
+
* @param options Optional loading options
|
|
1916
|
+
* @returns Promise resolving to array of named shader sources
|
|
1917
|
+
*/
|
|
1918
|
+
async loadShaders(shaders, options) {
|
|
1919
|
+
const results = await Promise.all(
|
|
1920
|
+
shaders.map(async (shader) => {
|
|
1921
|
+
const source = await this.loadShader(
|
|
1922
|
+
shader.vertex,
|
|
1923
|
+
shader.fragment,
|
|
1924
|
+
options
|
|
1925
|
+
);
|
|
1926
|
+
return {
|
|
1927
|
+
name: shader.name,
|
|
1928
|
+
...source
|
|
1929
|
+
};
|
|
1930
|
+
})
|
|
1931
|
+
);
|
|
1932
|
+
return results;
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Load resources from a manifest file
|
|
1936
|
+
* @param manifestPath Path to JSON manifest file
|
|
1937
|
+
* @param options Optional loading options
|
|
1938
|
+
* @returns Promise resolving to batch load result
|
|
1939
|
+
*/
|
|
1940
|
+
async loadFromManifest(manifestPath, options) {
|
|
1941
|
+
const manifestContent = await this.load(manifestPath, options);
|
|
1942
|
+
const manifest = JSON.parse(manifestContent);
|
|
1943
|
+
return this.loadBatch(manifest.resources, options);
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Preload resources for faster access later
|
|
1947
|
+
* @param paths Array of resource paths to preload
|
|
1948
|
+
* @param options Optional loading options
|
|
1949
|
+
* @returns Promise resolving when all resources are loaded
|
|
1950
|
+
*/
|
|
1951
|
+
async preload(paths, options) {
|
|
1952
|
+
await this.loadBatch(paths, options);
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* Check if a resource is cached
|
|
1956
|
+
* @param path Resource path
|
|
1957
|
+
* @returns true if the resource is in the cache
|
|
1958
|
+
*/
|
|
1959
|
+
isCached(path2) {
|
|
1960
|
+
return this.cache.has(path2);
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Get a resource from cache without loading
|
|
1964
|
+
* @param path Resource path
|
|
1965
|
+
* @returns Cached content or undefined if not cached
|
|
1966
|
+
*/
|
|
1967
|
+
getCached(path2) {
|
|
1968
|
+
return this.cache.get(path2);
|
|
1969
|
+
}
|
|
1970
|
+
/**
|
|
1971
|
+
* Clear the resource cache
|
|
1972
|
+
*/
|
|
1973
|
+
clearCache() {
|
|
1974
|
+
this.cache.clear();
|
|
1975
|
+
}
|
|
1976
|
+
/**
|
|
1977
|
+
* Get cache statistics
|
|
1978
|
+
* @returns Number of cached resources
|
|
1979
|
+
*/
|
|
1980
|
+
getCacheSize() {
|
|
1981
|
+
return this.cache.size();
|
|
1982
|
+
}
|
|
1983
|
+
/**
|
|
1984
|
+
* Enable caching
|
|
1985
|
+
*/
|
|
1986
|
+
enableCache() {
|
|
1987
|
+
this.cache.enable();
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Disable caching
|
|
1991
|
+
*/
|
|
1992
|
+
disableCache() {
|
|
1993
|
+
this.cache.disable();
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Set the maximum concurrency for batch operations
|
|
1997
|
+
* @param concurrency Maximum concurrent loads
|
|
1998
|
+
*/
|
|
1999
|
+
setConcurrency(concurrency) {
|
|
2000
|
+
this.concurrency = Math.max(1, concurrency);
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Get the underlying resource loader
|
|
2004
|
+
* @returns The resource loader instance
|
|
2005
|
+
*/
|
|
2006
|
+
getLoader() {
|
|
2007
|
+
return this.loader;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
async function createResourcePipeline(options) {
|
|
2011
|
+
const { ResourceLoaderFactory: ResourceLoaderFactory2 } = await Promise.resolve().then(() => resourceLoaderFactory);
|
|
2012
|
+
const loader = await ResourceLoaderFactory2.create({
|
|
2013
|
+
baseUrl: options?.baseUrl,
|
|
2014
|
+
baseDir: options?.baseDir,
|
|
2015
|
+
timeout: options?.timeout
|
|
2016
|
+
});
|
|
2017
|
+
return new ResourcePipeline(loader, options);
|
|
2018
|
+
}
|
|
2019
|
+
const SCENE_CONFIG = {
|
|
2020
|
+
width: 800,
|
|
2021
|
+
height: 600
|
|
2022
|
+
};
|
|
2023
|
+
const GEOMETRY = {
|
|
2024
|
+
quad: {
|
|
2025
|
+
vertices: new Float32Array([
|
|
2026
|
+
// Position TexCoord
|
|
2027
|
+
-0.5,
|
|
2028
|
+
-0.5,
|
|
2029
|
+
0,
|
|
2030
|
+
0,
|
|
2031
|
+
0,
|
|
2032
|
+
// Bottom-left
|
|
2033
|
+
0.5,
|
|
2034
|
+
-0.5,
|
|
2035
|
+
0,
|
|
2036
|
+
1,
|
|
2037
|
+
0,
|
|
2038
|
+
// Bottom-right
|
|
2039
|
+
0.5,
|
|
2040
|
+
0.5,
|
|
2041
|
+
0,
|
|
2042
|
+
1,
|
|
2043
|
+
1,
|
|
2044
|
+
// Top-right
|
|
2045
|
+
0.5,
|
|
2046
|
+
0.5,
|
|
2047
|
+
0,
|
|
2048
|
+
1,
|
|
2049
|
+
1,
|
|
2050
|
+
// Top-right
|
|
2051
|
+
-0.5,
|
|
2052
|
+
0.5,
|
|
2053
|
+
0,
|
|
2054
|
+
0,
|
|
2055
|
+
1,
|
|
2056
|
+
// Top-left
|
|
2057
|
+
-0.5,
|
|
2058
|
+
-0.5,
|
|
2059
|
+
0,
|
|
2060
|
+
0,
|
|
2061
|
+
0
|
|
2062
|
+
// Bottom-left
|
|
2063
|
+
]),
|
|
2064
|
+
stride: 5 * 4
|
|
2065
|
+
// 5 floats per vertex × 4 bytes
|
|
2066
|
+
}
|
|
2067
|
+
};
|
|
2068
|
+
const TEXTURE_CONFIG = {
|
|
2069
|
+
size: 256
|
|
2070
|
+
};
|
|
2071
|
+
const DEMO_CONFIG = {
|
|
2072
|
+
shaders: [
|
|
2073
|
+
{
|
|
2074
|
+
name: "basic",
|
|
2075
|
+
vertex: "resources/shaders/basic.vert",
|
|
2076
|
+
fragment: "resources/shaders/basic.frag"
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
name: "glow",
|
|
2080
|
+
vertex: "resources/shaders/glow.vert",
|
|
2081
|
+
fragment: "resources/shaders/glow.frag"
|
|
2082
|
+
}
|
|
2083
|
+
],
|
|
2084
|
+
// Additional resources to demonstrate batch loading
|
|
2085
|
+
resources: [
|
|
2086
|
+
"resources/shaders/basic.vert",
|
|
2087
|
+
"resources/shaders/basic.frag",
|
|
2088
|
+
"resources/shaders/glow.vert",
|
|
2089
|
+
"resources/shaders/glow.frag"
|
|
2090
|
+
]
|
|
2091
|
+
};
|
|
2092
|
+
async function runBrowserResourceLoaderDemo() {
|
|
2093
|
+
console.log("🩸 Bloody Engine - Resource Loader Demo");
|
|
2094
|
+
console.log("==========================================\n");
|
|
2095
|
+
const env = ResourceLoaderFactory.detectEnvironment();
|
|
2096
|
+
console.log(`✓ Environment detected: ${env}`);
|
|
2097
|
+
if (env !== Environment.BROWSER) {
|
|
2098
|
+
console.warn("⚠ This demo is designed for browser environment");
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
console.log("\n1. Creating Resource Pipeline...");
|
|
2102
|
+
const pipeline = await createResourcePipeline({
|
|
2103
|
+
concurrency: 5,
|
|
2104
|
+
cache: true,
|
|
2105
|
+
timeout: 1e4,
|
|
2106
|
+
baseUrl: window.location.origin
|
|
2107
|
+
});
|
|
2108
|
+
console.log("✓ Resource pipeline created");
|
|
2109
|
+
console.log(` - Concurrency: 5`);
|
|
2110
|
+
console.log(` - Caching: enabled`);
|
|
2111
|
+
console.log("\n2. Batch Loading Resources...");
|
|
2112
|
+
console.log(`Loading ${DEMO_CONFIG.resources.length} resources...`);
|
|
2113
|
+
const batchResult = await pipeline.loadBatch(DEMO_CONFIG.resources);
|
|
2114
|
+
console.log(`✓ Batch loading complete`);
|
|
2115
|
+
console.log(` - Succeeded: ${batchResult.successCount}`);
|
|
2116
|
+
console.log(` - Failed: ${batchResult.failureCount}`);
|
|
2117
|
+
if (batchResult.failureCount > 0) {
|
|
2118
|
+
console.log("\n❌ Failed resources:");
|
|
2119
|
+
for (const [path2, error] of batchResult.failed) {
|
|
2120
|
+
console.log(` - ${path2}: ${error}`);
|
|
2121
|
+
}
|
|
2122
|
+
console.log("\n⚠️ Falling back to inline shaders...");
|
|
2123
|
+
}
|
|
2124
|
+
console.log("\n3. Loading Shaders...");
|
|
2125
|
+
const shaders = await pipeline.loadShaders(DEMO_CONFIG.shaders);
|
|
2126
|
+
console.log(`✓ Loaded ${shaders.length} shaders:`);
|
|
2127
|
+
for (const shader2 of shaders) {
|
|
2128
|
+
console.log(` - ${shader2.name}:`);
|
|
2129
|
+
console.log(` Vertex: ${shader2.vertex.length} chars`);
|
|
2130
|
+
console.log(` Fragment: ${shader2.fragment.length} chars`);
|
|
2131
|
+
}
|
|
2132
|
+
console.log("\n4. Testing Cache...");
|
|
2133
|
+
const cachedSize = pipeline.getCacheSize();
|
|
2134
|
+
console.log(`✓ Cache contains ${cachedSize} resources`);
|
|
2135
|
+
for (const shaderConfig of DEMO_CONFIG.shaders) {
|
|
2136
|
+
const vertexCached = pipeline.isCached(shaderConfig.vertex);
|
|
2137
|
+
const fragmentCached = pipeline.isCached(shaderConfig.fragment);
|
|
2138
|
+
console.log(` - ${shaderConfig.name}:`);
|
|
2139
|
+
console.log(` Vertex cached: ${vertexCached}`);
|
|
2140
|
+
console.log(` Fragment cached: ${fragmentCached}`);
|
|
2141
|
+
}
|
|
2142
|
+
console.log("\n5. Initializing Graphics Device...");
|
|
2143
|
+
const gdevice = new GraphicsDevice(SCENE_CONFIG.width, SCENE_CONFIG.height);
|
|
2144
|
+
const gl = gdevice.getGLContext();
|
|
2145
|
+
console.log("✓ Graphics device initialized");
|
|
2146
|
+
console.log(` - Resolution: ${SCENE_CONFIG.width}x${SCENE_CONFIG.height}`);
|
|
2147
|
+
console.log("\n6. Creating Shader from Loaded Source...");
|
|
2148
|
+
let glowShader = shaders.find((s) => s.name === "glow");
|
|
2149
|
+
if (!glowShader || !glowShader.vertex || !glowShader.fragment) {
|
|
2150
|
+
console.warn("⚠️ Glow shader not loaded or empty, using inline fallback");
|
|
2151
|
+
glowShader = {
|
|
2152
|
+
name: "glow",
|
|
2153
|
+
vertex: `attribute vec3 aPosition;
|
|
2154
|
+
attribute vec2 aTexCoord;
|
|
2155
|
+
|
|
2156
|
+
varying vec2 vTexCoord;
|
|
2157
|
+
varying float vDistance;
|
|
2158
|
+
|
|
2159
|
+
uniform mat4 uMatrix;
|
|
2160
|
+
|
|
2161
|
+
void main() {
|
|
2162
|
+
gl_Position = uMatrix * vec4(aPosition, 1.0);
|
|
2163
|
+
vTexCoord = aTexCoord;
|
|
2164
|
+
vDistance = length(aTexCoord - vec2(0.5, 0.5));
|
|
2165
|
+
}`,
|
|
2166
|
+
fragment: `precision mediump float;
|
|
2167
|
+
|
|
2168
|
+
varying vec2 vTexCoord;
|
|
2169
|
+
varying float vDistance;
|
|
2170
|
+
uniform sampler2D uTexture;
|
|
2171
|
+
uniform vec3 uColor;
|
|
2172
|
+
uniform float uGlowIntensity;
|
|
2173
|
+
|
|
2174
|
+
void main() {
|
|
2175
|
+
vec4 texColor = texture2D(uTexture, vTexCoord);
|
|
2176
|
+
// Better glow falloff - keeps minimum brightness
|
|
2177
|
+
float glow = 1.0 - (vDistance * 0.7);
|
|
2178
|
+
glow = max(0.5, glow);
|
|
2179
|
+
vec3 glowColor = texColor.rgb * uColor * glow * uGlowIntensity;
|
|
2180
|
+
gl_FragColor = vec4(glowColor, texColor.a);
|
|
2181
|
+
}`
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
const shader = gdevice.createShader(glowShader.vertex, glowShader.fragment);
|
|
2185
|
+
console.log("✓ Shader compiled from loaded source code");
|
|
2186
|
+
console.log(` - Vertex shader: compiled`);
|
|
2187
|
+
console.log(` - Fragment shader: compiled`);
|
|
2188
|
+
console.log(` - Program: linked`);
|
|
2189
|
+
console.log("\n7. Creating Texture...");
|
|
2190
|
+
const texture = Texture.createGradient(
|
|
2191
|
+
gl,
|
|
2192
|
+
TEXTURE_CONFIG.size,
|
|
2193
|
+
TEXTURE_CONFIG.size
|
|
2194
|
+
);
|
|
2195
|
+
console.log("✓ Gradient texture created");
|
|
2196
|
+
console.log(` - Size: ${TEXTURE_CONFIG.size}x${TEXTURE_CONFIG.size}`);
|
|
2197
|
+
console.log("\n8. Creating Geometry Buffers...");
|
|
2198
|
+
const { VertexBuffer: VertexBuffer2 } = await Promise.resolve().then(() => buffer);
|
|
2199
|
+
const quadBuffer = new VertexBuffer2(
|
|
2200
|
+
gl,
|
|
2201
|
+
GEOMETRY.quad.vertices,
|
|
2202
|
+
GEOMETRY.quad.stride
|
|
2203
|
+
);
|
|
2204
|
+
console.log("✓ Quad buffer created");
|
|
2205
|
+
console.log(` - Vertices: ${quadBuffer.getVertexCount()}`);
|
|
2206
|
+
console.log("\n9. Setting up Rendering...");
|
|
2207
|
+
shader.use();
|
|
2208
|
+
const posAttr = shader.getAttributeLocation("aPosition");
|
|
2209
|
+
const texCoordAttr = shader.getAttributeLocation("aTexCoord");
|
|
2210
|
+
const textureUniform = shader.getUniformLocation("uTexture");
|
|
2211
|
+
const matrixUniform = shader.getUniformLocation("uMatrix");
|
|
2212
|
+
const colorUniform = shader.getUniformLocation("uColor");
|
|
2213
|
+
const glowIntensityUniform = shader.getUniformLocation("uGlowIntensity");
|
|
2214
|
+
quadBuffer.bind();
|
|
2215
|
+
gl.enableVertexAttribArray(posAttr);
|
|
2216
|
+
gl.vertexAttribPointer(posAttr, 3, gl.FLOAT, false, GEOMETRY.quad.stride, 0);
|
|
2217
|
+
gl.enableVertexAttribArray(texCoordAttr);
|
|
2218
|
+
gl.vertexAttribPointer(
|
|
2219
|
+
texCoordAttr,
|
|
2220
|
+
2,
|
|
2221
|
+
gl.FLOAT,
|
|
2222
|
+
false,
|
|
2223
|
+
GEOMETRY.quad.stride,
|
|
2224
|
+
3 * 4
|
|
2225
|
+
);
|
|
2226
|
+
console.log("✓ Vertex attributes configured");
|
|
2227
|
+
texture.bind(0);
|
|
2228
|
+
gl.uniform1i(textureUniform, 0);
|
|
2229
|
+
console.log("✓ Texture bound to unit 0");
|
|
2230
|
+
const renderingContext = gdevice.getRenderingContext();
|
|
2231
|
+
const canvas = renderingContext.canvas;
|
|
2232
|
+
if (canvas) {
|
|
2233
|
+
canvas.style.display = "block";
|
|
2234
|
+
canvas.style.margin = "0 auto";
|
|
2235
|
+
canvas.style.border = "2px solid #333";
|
|
2236
|
+
canvas.style.backgroundColor = "#1a1a1a";
|
|
2237
|
+
}
|
|
2238
|
+
document.body.style.margin = "0";
|
|
2239
|
+
document.body.style.padding = "20px";
|
|
2240
|
+
document.body.style.backgroundColor = "#0a0a0a";
|
|
2241
|
+
document.body.style.fontFamily = "monospace";
|
|
2242
|
+
document.body.style.color = "#aaa";
|
|
2243
|
+
const title = document.createElement("h1");
|
|
2244
|
+
title.textContent = "🩸 Resource Loader Demo";
|
|
2245
|
+
title.style.textAlign = "center";
|
|
2246
|
+
title.style.color = "#fff";
|
|
2247
|
+
if (canvas && canvas.parentNode) {
|
|
2248
|
+
canvas.parentNode.insertBefore(title, canvas);
|
|
2249
|
+
} else {
|
|
2250
|
+
document.body.insertBefore(title, document.body.firstChild);
|
|
2251
|
+
}
|
|
2252
|
+
const info = document.createElement("div");
|
|
2253
|
+
info.style.textAlign = "center";
|
|
2254
|
+
info.style.marginTop = "10px";
|
|
2255
|
+
info.style.fontSize = "12px";
|
|
2256
|
+
info.innerHTML = `
|
|
2257
|
+
<div>Environment: <strong>${env}</strong></div>
|
|
2258
|
+
<div>Shaders loaded: <strong>${shaders.length}</strong></div>
|
|
2259
|
+
<div>Cached resources: <strong>${cachedSize}</strong></div>
|
|
2260
|
+
`;
|
|
2261
|
+
document.body.appendChild(info);
|
|
2262
|
+
let frameCount = 0;
|
|
2263
|
+
const startTime = Date.now();
|
|
2264
|
+
function render() {
|
|
2265
|
+
const now = Date.now();
|
|
2266
|
+
const elapsedSeconds = (now - startTime) / 1e3;
|
|
2267
|
+
gdevice.clear({ r: 0.1, g: 0.1, b: 0.1, a: 1 });
|
|
2268
|
+
const quads = [
|
|
2269
|
+
{ x: -0.3, y: 0.3, color: [1, 0.2, 0.2], glow: 1.5 },
|
|
2270
|
+
{ x: 0.3, y: 0.3, color: [0.2, 1, 0.2], glow: 1.8 },
|
|
2271
|
+
{ x: -0.3, y: -0.3, color: [0.2, 0.5, 1], glow: 2 },
|
|
2272
|
+
{ x: 0.3, y: -0.3, color: [1, 1, 0.2], glow: 1.6 }
|
|
2273
|
+
];
|
|
2274
|
+
for (const quad of quads) {
|
|
2275
|
+
const matrix = createIdentityMatrix();
|
|
2276
|
+
translateMatrix(matrix, quad.x, quad.y, 0);
|
|
2277
|
+
scaleMatrix(matrix, 0.4, 0.4, 1);
|
|
2278
|
+
if (matrixUniform) {
|
|
2279
|
+
gl.uniformMatrix4fv(matrixUniform, false, matrix);
|
|
2280
|
+
}
|
|
2281
|
+
if (colorUniform) {
|
|
2282
|
+
gl.uniform3f(
|
|
2283
|
+
colorUniform,
|
|
2284
|
+
quad.color[0],
|
|
2285
|
+
quad.color[1],
|
|
2286
|
+
quad.color[2]
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
if (glowIntensityUniform) {
|
|
2290
|
+
const pulse = quad.glow + Math.sin(elapsedSeconds * 2) * 0.3;
|
|
2291
|
+
gl.uniform1f(glowIntensityUniform, pulse);
|
|
2292
|
+
}
|
|
2293
|
+
gl.drawArrays(gl.TRIANGLES, 0, quadBuffer.getVertexCount());
|
|
2294
|
+
}
|
|
2295
|
+
gdevice.present();
|
|
2296
|
+
frameCount++;
|
|
2297
|
+
const totalTime = (now - startTime) / 1e3;
|
|
2298
|
+
const fps = frameCount / totalTime;
|
|
2299
|
+
info.innerHTML = `
|
|
2300
|
+
<div>FPS: <strong>${fps.toFixed(1)}</strong> | Frame: <strong>${frameCount}</strong> | Elapsed: <strong>${totalTime.toFixed(2)}s</strong></div>
|
|
2301
|
+
<div>Environment: <strong>${env}</strong> | Shaders loaded: <strong>${shaders.length}</strong> | Cached: <strong>${cachedSize}</strong></div>
|
|
2302
|
+
`;
|
|
2303
|
+
requestAnimationFrame(render);
|
|
2304
|
+
}
|
|
2305
|
+
console.log("\n✓ Demo started! Rendering animation...");
|
|
2306
|
+
render();
|
|
2307
|
+
}
|
|
2308
|
+
function createIdentityMatrix() {
|
|
2309
|
+
return new Float32Array([
|
|
2310
|
+
1,
|
|
2311
|
+
0,
|
|
2312
|
+
0,
|
|
2313
|
+
0,
|
|
2314
|
+
0,
|
|
2315
|
+
1,
|
|
2316
|
+
0,
|
|
2317
|
+
0,
|
|
2318
|
+
0,
|
|
2319
|
+
0,
|
|
2320
|
+
1,
|
|
2321
|
+
0,
|
|
2322
|
+
0,
|
|
2323
|
+
0,
|
|
2324
|
+
0,
|
|
2325
|
+
1
|
|
2326
|
+
]);
|
|
2327
|
+
}
|
|
2328
|
+
function translateMatrix(mat, x, y, z) {
|
|
2329
|
+
mat[12] += x;
|
|
2330
|
+
mat[13] += y;
|
|
2331
|
+
mat[14] += z;
|
|
2332
|
+
}
|
|
2333
|
+
function scaleMatrix(mat, x, y, z) {
|
|
2334
|
+
mat[0] *= x;
|
|
2335
|
+
mat[5] *= y;
|
|
2336
|
+
mat[10] *= z;
|
|
2337
|
+
}
|
|
2338
|
+
if (typeof window !== "undefined") {
|
|
2339
|
+
runBrowserResourceLoaderDemo().catch((error) => {
|
|
2340
|
+
console.error("❌ Demo failed:", error);
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
export {
|
|
2344
|
+
BatchRenderer,
|
|
2345
|
+
BrowserRenderingContext,
|
|
2346
|
+
BrowserResourceLoader,
|
|
2347
|
+
Camera,
|
|
2348
|
+
Environment,
|
|
2349
|
+
GraphicsDevice,
|
|
2350
|
+
IndexBuffer,
|
|
2351
|
+
Matrix4,
|
|
2352
|
+
NodeRenderingContext,
|
|
2353
|
+
NodeResourceLoader,
|
|
2354
|
+
RenderingContextFactory,
|
|
2355
|
+
ResourceLoaderFactory,
|
|
2356
|
+
ResourcePipeline,
|
|
2357
|
+
Shader,
|
|
2358
|
+
SpriteBatchRenderer,
|
|
2359
|
+
Texture,
|
|
2360
|
+
VertexBuffer,
|
|
2361
|
+
createResourceLoader,
|
|
2362
|
+
createResourcePipeline,
|
|
2363
|
+
runBrowserResourceLoaderDemo
|
|
2364
|
+
};
|