From c165ed9b50544aa4e8f509e25b086839b6f8a357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Thu, 23 Oct 2025 16:42:19 +0300 Subject: [PATCH 01/13] Refactor EventScheduler to use PriorityQueue utility --- packages/core/src/event/event-scheduler.ts | 109 +++++---------------- 1 file changed, 23 insertions(+), 86 deletions(-) diff --git a/packages/core/src/event/event-scheduler.ts b/packages/core/src/event/event-scheduler.ts index 0167c5d..97a55b9 100644 --- a/packages/core/src/event/event-scheduler.ts +++ b/packages/core/src/event/event-scheduler.ts @@ -1,3 +1,5 @@ +import { PriorityQueue } from '@axrone/utility'; + declare const __taskBrand: unique symbol; declare const __schedulerBrand: unique symbol; @@ -70,80 +72,7 @@ interface ITask { promise?: Promise; } -interface IPriorityBucket { - tasks: T[]; - head: number; - tail: number; - size: number; -} - -class PriorityTaskQueue> { - private readonly buckets: IPriorityBucket[] = []; - private readonly bucketCapacity: number; - private totalSize = 0; - - constructor(bucketCapacity = 256) { - this.bucketCapacity = bucketCapacity; - - for (let i = 0; i <= TaskPriority.IDLE; i++) { - this.buckets[i] = { - tasks: new Array(bucketCapacity), - head: 0, - tail: 0, - size: 0, - }; - } - } - - enqueue(task: T, priority: TaskPriority): boolean { - const bucket = this.buckets[priority]; - - if (bucket.size >= this.bucketCapacity) { - return false; - } - - bucket.tasks[bucket.tail] = task; - bucket.tail = (bucket.tail + 1) % this.bucketCapacity; - bucket.size++; - this.totalSize++; - - return true; - } - - dequeue(): T | null { - for (let priority = TaskPriority.IMMEDIATE; priority <= TaskPriority.IDLE; priority++) { - const bucket = this.buckets[priority]; - if (bucket.size > 0) { - const task = bucket.tasks[bucket.head]; - bucket.head = (bucket.head + 1) % this.bucketCapacity; - bucket.size--; - this.totalSize--; - - return task; - } - } - - return null; - } - - get size(): number { - return this.totalSize; - } - - clear(): void { - for (const bucket of this.buckets) { - bucket.head = 0; - bucket.tail = 0; - bucket.size = 0; - } - this.totalSize = 0; - } - - getSizeByPriority(priority: TaskPriority): number { - return this.buckets[priority].size; - } -} export class EventScheduler { private readonly id: SchedulerId; @@ -157,7 +86,7 @@ export class EventScheduler { private readonly gcIntervalMs: number; private readonly name: string; - private readonly taskQueue = new PriorityTaskQueue>(); + private readonly taskQueue = new PriorityQueue, TaskPriority>(); private readonly activeTasks = new Map>(); private readonly taskMetrics = new Map(); @@ -173,7 +102,7 @@ export class EventScheduler { constructor(options: ISchedulerOptions = {}) { this.id = - `scheduler_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` as SchedulerId; + `scheduler_${Date.now()}_${Math.random().toString(36).substring(2, 11)}` as SchedulerId; this.concurrencyLimit = Math.max(1, options.concurrencyLimit ?? Infinity); this.maxQueueSize = Math.max(1, options.maxQueueSize ?? 10000); this.enableMetrics = options.enableMetrics ?? true; @@ -194,11 +123,11 @@ export class EventScheduler { } get queuedCount(): number { - return this.taskQueue.size; + return this.taskQueue.size as unknown as number; } get isAtCapacity(): boolean { - return this.taskQueue.size >= this.maxQueueSize; + return (this.taskQueue.size as unknown as number) >= this.maxQueueSize; } get disposed(): boolean { @@ -255,7 +184,9 @@ export class EventScheduler { }); } - if (!this.taskQueue.enqueue(task, priority)) { + try { + this.taskQueue.enqueue(task, priority); + } catch (error) { _reject(new Error('Failed to enqueue task')); return promise; } @@ -350,11 +281,13 @@ export class EventScheduler { } catch (error) {} }); - let task: ITask | null; - while ((task = this.taskQueue.dequeue()) !== null) { - try { - task.reject(new Error('Scheduler disposed')); - } catch (error) {} + while (!this.taskQueue.isEmpty) { + const task = this.taskQueue.tryDequeue(); + if (task) { + try { + task.reject(new Error('Scheduler disposed')); + } catch (error) {} + } } this.activeTasks.clear(); @@ -373,7 +306,7 @@ export class EventScheduler { if (this.isDisposed) return; while (this.activeCount < this.concurrencyLimit) { - const task = this.taskQueue.dequeue(); + const task = this.taskQueue.tryDequeue(); if (!task) break; this.executeTask(task); @@ -454,8 +387,12 @@ export class EventScheduler { setTimeout(() => { if (!this.isDisposed && !this.isAtCapacity) { - this.taskQueue.enqueue(task, task.priority); - this.processQueue(); + try { + this.taskQueue.enqueue(task, task.priority); + this.processQueue(); + } catch { + task.reject(error); + } } else { task.reject(error); } From a57b696d9f42d70dd90d37d741b3849cbef2a821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Thu, 23 Oct 2025 16:50:06 +0300 Subject: [PATCH 02/13] Refactor ParticleSystemRenderer for SOA data layout --- .../src/particle-system/particle-renderer.ts | 390 ++++++++---------- 1 file changed, 171 insertions(+), 219 deletions(-) diff --git a/packages/core/src/particle-system/particle-renderer.ts b/packages/core/src/particle-system/particle-renderer.ts index 87a8b70..2c320dc 100644 --- a/packages/core/src/particle-system/particle-renderer.ts +++ b/packages/core/src/particle-system/particle-renderer.ts @@ -73,13 +73,13 @@ export enum BlendMode { Screen = 4, Overlay = 5, SoftAdditive = 6, - PremultipliedAlpha = 7 + PremultipliedAlpha = 7, } export enum CullMode { None = 0, Front = 1, - Back = 2 + Back = 2, } export enum TextureFormat { @@ -90,7 +90,7 @@ export enum TextureFormat { RGB32F = 4, RGBA32F = 5, Depth24 = 6, - Depth32F = 7 + Depth32F = 7, } export enum UniformType { @@ -101,7 +101,7 @@ export enum UniformType { Mat3 = 4, Mat4 = 5, Sampler2D = 6, - SamplerCube = 7 + SamplerCube = 7, } export enum AttributeType { @@ -112,7 +112,7 @@ export enum AttributeType { Int = 4, IVec2 = 5, IVec3 = 6, - IVec4 = 7 + IVec4 = 7, } export interface RenderStats { @@ -138,21 +138,6 @@ export interface RenderSettings { occlusionCulling: boolean; } -/** - * High-performance particle system renderer with advanced features. - * - * Features: - * - Multi-pass rendering with depth sorting - * - Instanced rendering for performance - * - Texture atlas optimization - * - Level-of-detail (LOD) support - * - Frustum and occlusion culling - * - Advanced blending modes - * - Material batching - * - GPU-accelerated sorting - * - Memory pool management - * - Performance profiling - */ export class ParticleSystemRenderer { private readonly _settings: RenderSettings; private readonly _renderBatches: IRenderBatch[] = []; @@ -160,16 +145,14 @@ export class ParticleSystemRenderer { private readonly _tempIndices: Uint32Array; private readonly _sortKeys: Float32Array; private readonly _histogram: Uint32Array; - - // Instance data buffers + private readonly _instancePositions: Float32Array; private readonly _instanceColors: Float32Array; private readonly _instanceSizes: Float32Array; private readonly _instanceRotations: Float32Array; private readonly _instanceUVs: Float32Array; private readonly _instanceCustom: Float32Array; - - // Performance tracking + private readonly _stats: RenderStats = { totalParticles: 0, renderedParticles: 0, @@ -178,14 +161,12 @@ export class ParticleSystemRenderer { sortTime: 0, batchTime: 0, renderTime: 0, - memoryUsage: 0 + memoryUsage: 0, }; - - // Material cache + private readonly _materialCache = new Map(); private readonly _batchCache = new Map(); - - // Culling + private readonly _frustumPlanes: Float32Array = new Float32Array(24); // 6 planes * 4 components private readonly _visibilityFlags: Uint8Array; @@ -200,107 +181,83 @@ export class ParticleSystemRenderer { cullingEnabled: true, frustumCulling: true, occlusionCulling: false, - ...settings + ...settings, }; - - // Allocate arrays + this._sortedIndices = new Uint32Array(maxParticles); this._tempIndices = new Uint32Array(maxParticles); this._sortKeys = new Float32Array(maxParticles); this._histogram = new Uint32Array(256); this._visibilityFlags = new Uint8Array(maxParticles); - - // Instance data buffers (worst case: all particles in separate instances) + this._instancePositions = new Float32Array(maxParticles * 3); this._instanceColors = new Float32Array(maxParticles * 4); this._instanceSizes = new Float32Array(maxParticles * 3); this._instanceRotations = new Float32Array(maxParticles * 4); // Quaternion this._instanceUVs = new Float32Array(maxParticles * 4); // UV rect this._instanceCustom = new Float32Array(maxParticles * 8); // Custom data - + this._initializeArrays(); } - /** - * Initialize arrays with default values - */ private _initializeArrays(): void { - // Initialize indices for (let i = 0; i < this._sortedIndices.length; i++) { this._sortedIndices[i] = i; } - - // Initialize visibility flags to visible + this._visibilityFlags.fill(1); - - // Initialize UV rects to full texture (0,0,1,1) + for (let i = 0; i < this._instanceUVs.length; i += 4) { this._instanceUVs[i + 2] = 1; // width this._instanceUVs[i + 3] = 1; // height } } - /** - * Update frustum planes for culling - */ updateFrustum(viewProjectionMatrix: Float32Array): void { if (!this._settings.frustumCulling) return; - - // Extract frustum planes from view-projection matrix - // Implementation would extract 6 planes from the matrix - // For now, we'll use a simplified version + this._extractFrustumPlanes(viewProjectionMatrix); } - /** - * Extract frustum planes from view-projection matrix - */ private _extractFrustumPlanes(matrix: Float32Array): void { - // Left plane this._frustumPlanes[0] = matrix[3] + matrix[0]; this._frustumPlanes[1] = matrix[7] + matrix[4]; this._frustumPlanes[2] = matrix[11] + matrix[8]; this._frustumPlanes[3] = matrix[15] + matrix[12]; - - // Right plane + this._frustumPlanes[4] = matrix[3] - matrix[0]; this._frustumPlanes[5] = matrix[7] - matrix[4]; this._frustumPlanes[6] = matrix[11] - matrix[8]; this._frustumPlanes[7] = matrix[15] - matrix[12]; - - // Bottom plane + this._frustumPlanes[8] = matrix[3] + matrix[1]; this._frustumPlanes[9] = matrix[7] + matrix[5]; this._frustumPlanes[10] = matrix[11] + matrix[9]; this._frustumPlanes[11] = matrix[15] + matrix[13]; - - // Top plane + this._frustumPlanes[12] = matrix[3] - matrix[1]; this._frustumPlanes[13] = matrix[7] - matrix[5]; this._frustumPlanes[14] = matrix[11] - matrix[9]; this._frustumPlanes[15] = matrix[15] - matrix[13]; - - // Near plane + this._frustumPlanes[16] = matrix[3] + matrix[2]; this._frustumPlanes[17] = matrix[7] + matrix[6]; this._frustumPlanes[18] = matrix[11] + matrix[10]; this._frustumPlanes[19] = matrix[15] + matrix[14]; - - // Far plane + this._frustumPlanes[20] = matrix[3] - matrix[2]; this._frustumPlanes[21] = matrix[7] - matrix[6]; this._frustumPlanes[22] = matrix[11] - matrix[10]; this._frustumPlanes[23] = matrix[15] - matrix[14]; - - // Normalize planes + for (let i = 0; i < 6; i++) { const offset = i * 4; const length = Math.sqrt( this._frustumPlanes[offset] * this._frustumPlanes[offset] + - this._frustumPlanes[offset + 1] * this._frustumPlanes[offset + 1] + - this._frustumPlanes[offset + 2] * this._frustumPlanes[offset + 2] + this._frustumPlanes[offset + 1] * this._frustumPlanes[offset + 1] + + this._frustumPlanes[offset + 2] * this._frustumPlanes[offset + 2] ); - + if (length > 0) { this._frustumPlanes[offset] /= length; this._frustumPlanes[offset + 1] /= length; @@ -310,70 +267,58 @@ export class ParticleSystemRenderer { } } - /** - * Perform frustum culling on particles - */ private _performFrustumCulling(particles: ParticleSOA): void { if (!this._settings.frustumCulling) { this._visibilityFlags.fill(1); return; } - + const positions = particles.positions; const sizes = particles.sizes; const count = particles.count; - + for (let i = 0; i < count; i++) { const px = positions[i * 3]; const py = positions[i * 3 + 1]; const pz = positions[i * 3 + 2]; const radius = Math.max(sizes[i * 3], sizes[i * 3 + 1], sizes[i * 3 + 2]) * 0.5; - + let visible = true; - - // Test against all 6 frustum planes + for (let planeIndex = 0; planeIndex < 6 && visible; planeIndex++) { const offset = planeIndex * 4; - const distance = + const distance = this._frustumPlanes[offset] * px + this._frustumPlanes[offset + 1] * py + this._frustumPlanes[offset + 2] * pz + this._frustumPlanes[offset + 3]; - + if (distance < -radius) { visible = false; } } - + this._visibilityFlags[i] = visible ? 1 : 0; } } - /** - * Sort particles with high-performance radix sort - */ sortParticles( - particles: ParticleSOA, - sortMode: SortMode, + particles: ParticleSOA, + sortMode: SortMode, cameraPosition?: { x: number; y: number; z: number } ): void { if (sortMode === SortMode.None || !this._settings.enableDepthSort) return; - + const startTime = performance.now(); const count = particles.count; - - // Generate sort keys + this._generateSortKeys(particles, sortMode, cameraPosition, count); - - // Perform radix sort + this._radixSort(this._sortedIndices, this._sortKeys, count); - + this._stats.sortTime = performance.now() - startTime; } - /** - * Generate sort keys based on sort mode - */ private _generateSortKeys( particles: ParticleSOA, sortMode: SortMode, @@ -382,10 +327,10 @@ export class ParticleSystemRenderer { ): void { const positions = particles.positions; const ages = particles.ages; - + for (let i = 0; i < count; i++) { this._sortedIndices[i] = i; - + switch (sortMode) { case SortMode.Distance: if (cameraPosition) { @@ -409,59 +354,46 @@ export class ParticleSystemRenderer { } } - /** - * High-performance radix sort implementation - */ private _radixSort(indices: Uint32Array, keys: Float32Array, count: number): void { - // Convert float keys to uint32 for radix sort const keyInts = new Uint32Array(keys.buffer, keys.byteOffset, count); const tempInts = new Uint32Array(count); - + let sourceIndices = indices; let targetIndices = this._tempIndices; - - // Sort by each byte (4 passes for 32-bit values) + for (let pass = 0; pass < 4; pass++) { this._histogram.fill(0); - - // Count occurrences + for (let i = 0; i < count; i++) { const key = keyInts[sourceIndices[i]]; - const byte = (key >>> (pass * 8)) & 0xFF; + const byte = (key >>> (pass * 8)) & 0xff; this._histogram[byte]++; } - - // Calculate offsets + let offset = 0; for (let i = 0; i < 256; i++) { const temp = this._histogram[i]; this._histogram[i] = offset; offset += temp; } - - // Distribute + for (let i = 0; i < count; i++) { const index = sourceIndices[i]; const key = keyInts[index]; - const byte = (key >>> (pass * 8)) & 0xFF; + const byte = (key >>> (pass * 8)) & 0xff; targetIndices[this._histogram[byte]++] = index; } - - // Swap arrays + const temp = sourceIndices; sourceIndices = targetIndices; targetIndices = temp; } - - // Copy result back to original array if necessary + if (sourceIndices !== indices) { indices.set(sourceIndices.subarray(0, count)); } } - /** - * Create optimized render batches - */ createRenderBatches( particles: ParticleSOA, materials: readonly IMaterial[], @@ -469,34 +401,33 @@ export class ParticleSystemRenderer { ): readonly IRenderBatch[] { const startTime = performance.now(); this._renderBatches.length = 0; - - // Perform culling first + this._performFrustumCulling(particles); - - // Count visible particles + + const activeIndices = particles.getActiveIndices(); let visibleCount = 0; - for (let i = 0; i < particles.count; i++) { - if (this._visibilityFlags[i] && particles.active[i]) { + for (const index of activeIndices) { + if (this._visibilityFlags[index]) { visibleCount++; } } - + if (visibleCount === 0) { this._stats.renderedParticles = 0; this._stats.batchCount = 0; return this._renderBatches; } - + // Generate batches based on material and texture this._generateBatches(particles, materials, textures); - + // Sort batches by priority and blend mode this._sortBatches(); - + this._stats.batchTime = performance.now() - startTime; this._stats.renderedParticles = visibleCount; this._stats.batchCount = this._renderBatches.length; - + return this._renderBatches; } @@ -513,117 +444,138 @@ export class ParticleSystemRenderer { let batchStart = 0; let batchCount = 0; let instanceDataOffset = 0; - - for (let i = 0; i < particles.count; i++) { - const sortedIndex = this._sortedIndices[i]; - - if (!particles.active[sortedIndex] || !this._visibilityFlags[sortedIndex]) { + + const activeIndices = particles.getActiveIndices(); + + for (let i = 0; i < activeIndices.length; i++) { + const sortedIndex = + this._sortedIndices[i] < activeIndices.length + ? activeIndices[this._sortedIndices[i]] + : activeIndices[i]; + + if (!this._visibilityFlags[sortedIndex]) { continue; } - - const materialIndex = particles.materialIndices?.[sortedIndex] || 0; - const material = materials[materialIndex] || materials[0]; - const texture = textures?.[particles.textureIndices?.[sortedIndex] || 0] || material.texture || null; - + + // Use default material and texture since ParticleSOA doesn't have these properties + const material = materials[0] || { + id: 'default', + shader: { + id: 'default', + vertexSource: '', + fragmentSource: '', + uniforms: {}, + attributes: {}, + }, + blendMode: BlendMode.Alpha, + sortMode: SortMode.Distance, + priority: 0, + cullMode: CullMode.None, + depthTest: true, + depthWrite: false, + properties: {}, + }; + const texture = textures?.[0] || material.texture || null; + // Check if we need to start a new batch - const needNewBatch = + const needNewBatch = currentMaterial !== material || currentTexture !== texture || batchCount >= this._settings.maxBatchSize; - + if (needNewBatch && batchCount > 0) { // Finalize current batch - this._finalizeBatch(currentMaterial!, currentTexture, batchStart, batchCount, instanceDataOffset); + this._finalizeBatch( + currentMaterial!, + currentTexture, + batchStart, + batchCount, + instanceDataOffset + ); batchStart = i; batchCount = 0; instanceDataOffset += batchCount; } - + if (needNewBatch) { currentMaterial = material; currentTexture = texture; } - + // Add particle to instance data - this._addParticleToInstanceData(particles, sortedIndex, instanceDataOffset + batchCount); + this._addParticleToInstanceData( + particles, + sortedIndex, + instanceDataOffset + batchCount + ); batchCount++; } - + // Finalize last batch if (batchCount > 0 && currentMaterial) { - this._finalizeBatch(currentMaterial, currentTexture, batchStart, batchCount, instanceDataOffset); + this._finalizeBatch( + currentMaterial, + currentTexture, + batchStart, + batchCount, + instanceDataOffset + ); } } /** * Add particle data to instance buffers */ - private _addParticleToInstanceData(particles: ParticleSOA, particleIndex: number, instanceIndex: number): void { + private _addParticleToInstanceData( + particles: ParticleSOA, + particleIndex: number, + instanceIndex: number + ): void { const posOffset = instanceIndex * 3; const colorOffset = instanceIndex * 4; const sizeOffset = instanceIndex * 3; const rotOffset = instanceIndex * 4; const uvOffset = instanceIndex * 4; const customOffset = instanceIndex * 8; - + // Position - this._instancePositions[posOffset] = particles.positions.x[particleIndex]; - this._instancePositions[posOffset + 1] = particles.positions.y[particleIndex]; - this._instancePositions[posOffset + 2] = particles.positions.z![particleIndex]; - + this._instancePositions[posOffset] = particles.positions[particleIndex * 3]; + this._instancePositions[posOffset + 1] = particles.positions[particleIndex * 3 + 1]; + this._instancePositions[posOffset + 2] = particles.positions[particleIndex * 3 + 2]; + // Color - this._instanceColors[colorOffset] = particles.colors.x[particleIndex]; - this._instanceColors[colorOffset + 1] = particles.colors.y[particleIndex]; - this._instanceColors[colorOffset + 2] = particles.colors.z![particleIndex]; - this._instanceColors[colorOffset + 3] = particles.colors.w![particleIndex]; - + this._instanceColors[colorOffset] = particles.colors[particleIndex * 4]; + this._instanceColors[colorOffset + 1] = particles.colors[particleIndex * 4 + 1]; + this._instanceColors[colorOffset + 2] = particles.colors[particleIndex * 4 + 2]; + this._instanceColors[colorOffset + 3] = particles.colors[particleIndex * 4 + 3]; + // Size - this._instanceSizes[sizeOffset] = particles.sizes.x[particleIndex]; - this._instanceSizes[sizeOffset + 1] = particles.sizes.y[particleIndex]; - this._instanceSizes[sizeOffset + 2] = particles.sizes.z![particleIndex]; - - // Rotation (quaternion) - if (particles.rotations) { - this._instanceRotations[rotOffset] = particles.rotations.x[particleIndex]; - this._instanceRotations[rotOffset + 1] = particles.rotations.y[particleIndex]; - this._instanceRotations[rotOffset + 2] = particles.rotations.z![particleIndex]; - this._instanceRotations[rotOffset + 3] = particles.rotations.w![particleIndex]; - } else { - // Identity quaternion - this._instanceRotations[rotOffset] = 0; - this._instanceRotations[rotOffset + 1] = 0; - this._instanceRotations[rotOffset + 2] = 0; - this._instanceRotations[rotOffset + 3] = 1; - } - - // UV coordinates (texture atlas) - if (particles.uvRects) { - this._instanceUVs[uvOffset] = particles.uvRects.x[particleIndex]; - this._instanceUVs[uvOffset + 1] = particles.uvRects.y[particleIndex]; - this._instanceUVs[uvOffset + 2] = particles.uvRects.z![particleIndex]; - this._instanceUVs[uvOffset + 3] = particles.uvRects.w![particleIndex]; - } else { - // Full texture - this._instanceUVs[uvOffset] = 0; - this._instanceUVs[uvOffset + 1] = 0; - this._instanceUVs[uvOffset + 2] = 1; - this._instanceUVs[uvOffset + 3] = 1; - } - + this._instanceSizes[sizeOffset] = particles.sizes[particleIndex * 3]; + this._instanceSizes[sizeOffset + 1] = particles.sizes[particleIndex * 3 + 1]; + this._instanceSizes[sizeOffset + 2] = particles.sizes[particleIndex * 3 + 2]; + + // Rotation (convert from Euler to quaternion or use default) + this._instanceRotations[rotOffset] = 0; + this._instanceRotations[rotOffset + 1] = 0; + this._instanceRotations[rotOffset + 2] = 0; + this._instanceRotations[rotOffset + 3] = 1; // Identity quaternion + + // UV coordinates (default to full texture) + this._instanceUVs[uvOffset] = 0; + this._instanceUVs[uvOffset + 1] = 0; + this._instanceUVs[uvOffset + 2] = 1; + this._instanceUVs[uvOffset + 3] = 1; + // Custom data - if (particles.customData1) { - this._instanceCustom[customOffset] = particles.customData1.x[particleIndex]; - this._instanceCustom[customOffset + 1] = particles.customData1.y[particleIndex]; - this._instanceCustom[customOffset + 2] = particles.customData1.z![particleIndex]; - this._instanceCustom[customOffset + 3] = particles.customData1.w![particleIndex]; - } - - if (particles.customData2) { - this._instanceCustom[customOffset + 4] = particles.customData2.x[particleIndex]; - this._instanceCustom[customOffset + 5] = particles.customData2.y[particleIndex]; - this._instanceCustom[customOffset + 6] = particles.customData2.z![particleIndex]; - this._instanceCustom[customOffset + 7] = particles.customData2.w![particleIndex]; - } + this._instanceCustom[customOffset] = particles.customData1[particleIndex * 4]; + this._instanceCustom[customOffset + 1] = particles.customData1[particleIndex * 4 + 1]; + this._instanceCustom[customOffset + 2] = particles.customData1[particleIndex * 4 + 2]; + this._instanceCustom[customOffset + 3] = particles.customData1[particleIndex * 4 + 3]; + + this._instanceCustom[customOffset + 4] = particles.customData2[particleIndex * 4]; + this._instanceCustom[customOffset + 5] = particles.customData2[particleIndex * 4 + 1]; + this._instanceCustom[customOffset + 6] = particles.customData2[particleIndex * 4 + 2]; + this._instanceCustom[customOffset + 7] = particles.customData2[particleIndex * 4 + 3]; } /** @@ -639,38 +591,38 @@ export class ParticleSystemRenderer { // Create instance data for this batch const instanceData = new Float32Array(count * 24); // 3+4+3+4+4+8 = 26 components per instance let offset = 0; - + for (let i = 0; i < count; i++) { const srcIndex = instanceDataOffset + i; - + // Position (3) instanceData[offset++] = this._instancePositions[srcIndex * 3]; instanceData[offset++] = this._instancePositions[srcIndex * 3 + 1]; instanceData[offset++] = this._instancePositions[srcIndex * 3 + 2]; - + // Color (4) instanceData[offset++] = this._instanceColors[srcIndex * 4]; instanceData[offset++] = this._instanceColors[srcIndex * 4 + 1]; instanceData[offset++] = this._instanceColors[srcIndex * 4 + 2]; instanceData[offset++] = this._instanceColors[srcIndex * 4 + 3]; - + // Size (3) instanceData[offset++] = this._instanceSizes[srcIndex * 3]; instanceData[offset++] = this._instanceSizes[srcIndex * 3 + 1]; instanceData[offset++] = this._instanceSizes[srcIndex * 3 + 2]; - + // Rotation (4) instanceData[offset++] = this._instanceRotations[srcIndex * 4]; instanceData[offset++] = this._instanceRotations[srcIndex * 4 + 1]; instanceData[offset++] = this._instanceRotations[srcIndex * 4 + 2]; instanceData[offset++] = this._instanceRotations[srcIndex * 4 + 3]; - + // UV (4) instanceData[offset++] = this._instanceUVs[srcIndex * 4]; instanceData[offset++] = this._instanceUVs[srcIndex * 4 + 1]; instanceData[offset++] = this._instanceUVs[srcIndex * 4 + 2]; instanceData[offset++] = this._instanceUVs[srcIndex * 4 + 3]; - + // Custom (6, reduced from 8 to fit in 24) instanceData[offset++] = this._instanceCustom[srcIndex * 8]; instanceData[offset++] = this._instanceCustom[srcIndex * 8 + 1]; @@ -679,7 +631,7 @@ export class ParticleSystemRenderer { instanceData[offset++] = this._instanceCustom[srcIndex * 8 + 4]; instanceData[offset++] = this._instanceCustom[srcIndex * 8 + 5]; } - + const batch: IRenderBatch = { startIndex, count, @@ -688,9 +640,9 @@ export class ParticleSystemRenderer { blendMode: material.blendMode, sortMode: material.sortMode, priority: material.priority, - instanceData + instanceData, }; - + this._renderBatches.push(batch); } @@ -703,14 +655,14 @@ export class ParticleSystemRenderer { if (a.priority !== b.priority) { return a.priority - b.priority; } - + // Then by blend mode (opaque first, then transparent) const aOpaque = a.blendMode === BlendMode.Opaque ? 0 : 1; const bOpaque = b.blendMode === BlendMode.Opaque ? 0 : 1; if (aOpaque !== bOpaque) { return aOpaque - bOpaque; } - + // Finally by material ID for batching return a.material.id.localeCompare(b.material.id); }); @@ -729,14 +681,14 @@ export class ParticleSystemRenderer { */ private _calculateMemoryUsage(): number { let totalBytes = 0; - + // Core arrays totalBytes += this._sortedIndices.byteLength; totalBytes += this._tempIndices.byteLength; totalBytes += this._sortKeys.byteLength; totalBytes += this._histogram.byteLength; totalBytes += this._visibilityFlags.byteLength; - + // Instance data totalBytes += this._instancePositions.byteLength; totalBytes += this._instanceColors.byteLength; @@ -744,12 +696,12 @@ export class ParticleSystemRenderer { totalBytes += this._instanceRotations.byteLength; totalBytes += this._instanceUVs.byteLength; totalBytes += this._instanceCustom.byteLength; - + // Batches for (const batch of this._renderBatches) { totalBytes += batch.instanceData.byteLength; } - + return totalBytes / 1024; // KB } From 9ae9a0c175e8c2d51eb84784a6a957e0322b56f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Thu, 23 Oct 2025 16:52:56 +0300 Subject: [PATCH 03/13] Add ShaderCache for managing WebGL2 shader programs --- .../renderer/webgl2/shader/shader-cache.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 packages/core/src/renderer/webgl2/shader/shader-cache.ts diff --git a/packages/core/src/renderer/webgl2/shader/shader-cache.ts b/packages/core/src/renderer/webgl2/shader/shader-cache.ts new file mode 100644 index 0000000..91c2ad9 --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/shader-cache.ts @@ -0,0 +1,58 @@ +export class ShaderCache { + private readonly programs = new Map(); + private readonly gl: WebGL2RenderingContext; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + } + + getOrCreate( + vertexSource: string, + fragmentSource: string, + defines: Record = {}, + key = this.generateKey(vertexSource, fragmentSource, defines) + ): ShaderProgram { + if (this.programs.has(key)) { + return this.programs.get(key)!; + } + + const program = createShaderProgram(this.gl, vertexSource, fragmentSource, { defines }); + this.programs.set(key, program); + + return program; + } + + private generateKey(vertexSource: string, fragmentSource: string, defines: Record): string { + const definesKey = Object.entries(defines) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}:${value}`) + .join('|'); + + const vsHash = this.hashString(vertexSource); + const fsHash = this.hashString(fragmentSource); + const defHash = this.hashString(definesKey); + + return `${vsHash}_${fsHash}_${defHash}`; + } + + private hashString(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(36); + } + + clear(): void { + for (const program of this.programs.values()) { + program.dispose(); + } + this.programs.clear(); + } + + dispose(): void { + this.clear(); + } +} \ No newline at end of file From e3ec7bf23e9e80a5435c244539d0a04974b8d34e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Thu, 23 Oct 2025 16:59:47 +0300 Subject: [PATCH 04/13] Add WebGL2 material system core classes --- .../renderer/webgl2/material/base-material.ts | 521 +++++++++++++++ .../src/renderer/webgl2/material/index.ts | 36 + .../webgl2/material/material-manager.ts | 433 ++++++++++++ .../renderer/webgl2/material/pbr-material.ts | 579 ++++++++++++++++ .../webgl2/material/standard-material.ts | 618 ++++++++++++++++++ 5 files changed, 2187 insertions(+) create mode 100644 packages/core/src/renderer/webgl2/material/base-material.ts create mode 100644 packages/core/src/renderer/webgl2/material/index.ts create mode 100644 packages/core/src/renderer/webgl2/material/material-manager.ts create mode 100644 packages/core/src/renderer/webgl2/material/pbr-material.ts create mode 100644 packages/core/src/renderer/webgl2/material/standard-material.ts diff --git a/packages/core/src/renderer/webgl2/material/base-material.ts b/packages/core/src/renderer/webgl2/material/base-material.ts new file mode 100644 index 0000000..a681a1e --- /dev/null +++ b/packages/core/src/renderer/webgl2/material/base-material.ts @@ -0,0 +1,521 @@ +import { Component, ComponentConfig } from '../../../component-system/core/component'; + +export interface Vec2 { x: number; y: number; } +export interface Vec3 { x: number; y: number; z: number; } +export interface Vec4 { x: number; y: number; z: number; w: number; } +export interface Mat3 { elements: Float32Array; } +export interface Mat4 { elements: Float32Array; } + +export const enum MaterialType { + STANDARD = 'Standard', + UNLIT = 'Unlit', + PBR = 'PBR', + PARTICLE = 'Particle', + TOON = 'Toon', + GLASS = 'Glass', + METAL = 'Metal', + SKIN = 'Skin', + VEGETATION = 'Vegetation', + WATER = 'Water', + CUSTOM = 'Custom' +} + +export const enum BlendMode { + OPAQUE = 'Opaque', + ALPHA_BLEND = 'AlphaBlend', + ALPHA_TEST = 'AlphaTest', + ADDITIVE = 'Additive', + MULTIPLY = 'Multiply', + SCREEN = 'Screen', + OVERLAY = 'Overlay', + SOFT_ADDITIVE = 'SoftAdditive', + PREMULTIPLIED = 'Premultiplied' +} + +export const enum CullMode { + NONE = 'None', + FRONT = 'Front', + BACK = 'Back' +} + +export const enum DepthTest { + DISABLED = 'Disabled', + NEVER = 'Never', + LESS = 'Less', + EQUAL = 'Equal', + LEQUAL = 'LEqual', + GREATER = 'Greater', + NOTEQUAL = 'NotEqual', + GEQUAL = 'GEqual', + ALWAYS = 'Always' +} + +export const enum ShadowCasting { + OFF = 'Off', + ON = 'On', + TWO_SIDED = 'TwoSided', + SHADOWS_ONLY = 'ShadowsOnly' +} + +export const enum LightMode { + FORWARD_BASE = 'ForwardBase', + FORWARD_ADD = 'ForwardAdd', + DEFERRED = 'Deferred', + SHADOW_CASTER = 'ShadowCaster', + DEPTH_ONLY = 'DepthOnly', + META = 'Meta' +} + +export type MaterialPropertyValue = + | number + | boolean + | Vec2 + | Vec3 + | Vec4 + | Mat3 + | Mat4 + | WebGLTexture + | string + | Float32Array + | Int32Array + | Uint32Array + | null; + +export interface MaterialProperty { + readonly name: string; + readonly displayName: string; + readonly type: 'float' | 'int' | 'bool' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'texture' | 'matrix'; + readonly defaultValue: MaterialPropertyValue; + readonly range?: { min: number; max: number }; + readonly category?: string; + readonly tooltip?: string; + readonly hidden?: boolean; + readonly system?: boolean; +} + +export interface MaterialKeyword { + readonly name: string; + readonly displayName: string; + readonly description?: string; + readonly category?: string; + readonly mutuallyExclusive?: string[]; + readonly dependencies?: string[]; +} + +export interface MaterialConfig extends ComponentConfig { + readonly materialType: MaterialType; + readonly shaderName?: string; + readonly renderQueue?: number; + readonly blendMode?: BlendMode; + readonly cullMode?: CullMode; + readonly depthTest?: DepthTest; + readonly depthWrite?: boolean; + readonly shadowCasting?: ShadowCasting; + readonly receiveShadows?: boolean; + readonly lightMode?: LightMode; + readonly properties?: Record; + readonly keywords?: string[]; + readonly renderTags?: Record; +} + +export interface StandardMaterialConfig extends MaterialConfig { + materialType: MaterialType.STANDARD; + albedo?: Vec4; + albedoMap?: WebGLTexture; + metallic?: number; + metallicMap?: WebGLTexture; + roughness?: number; + roughnessMap?: WebGLTexture; + normalMap?: WebGLTexture; + normalScale?: number; + heightMap?: WebGLTexture; + heightScale?: number; + occlusionMap?: WebGLTexture; + occlusionStrength?: number; + emission?: Vec3; + emissionMap?: WebGLTexture; + emissionIntensity?: number; +} + +export interface PBRMaterialConfig extends MaterialConfig { + materialType: MaterialType.PBR; + baseColor?: Vec4; + baseColorTexture?: WebGLTexture; + metallicFactor?: number; + roughnessFactor?: number; + metallicRoughnessTexture?: WebGLTexture; + normalTexture?: WebGLTexture; + normalScale?: number; + occlusionTexture?: WebGLTexture; + occlusionStrength?: number; + emissiveTexture?: WebGLTexture; + emissiveFactor?: Vec3; + alphaMode?: 'OPAQUE' | 'MASK' | 'BLEND'; + alphaCutoff?: number; + doubleSided?: boolean; +} + +export interface UnlitMaterialConfig extends MaterialConfig { + materialType: MaterialType.UNLIT; + color?: Vec4; + mainTexture?: WebGLTexture; + cutoff?: number; +} + +export abstract class BaseMaterialComponent extends Component { + protected _properties = new Map(); + protected _keywords = new Set(); + protected _renderQueue: number = 2000; + protected _isDirty: boolean = true; + protected _lastModified: number = 0; + + protected _blendMode: BlendMode = BlendMode.OPAQUE; + protected _cullMode: CullMode = CullMode.BACK; + protected _depthTest: DepthTest = DepthTest.LESS; + protected _depthWrite: boolean = true; + protected _shadowCasting: ShadowCasting = ShadowCasting.ON; + protected _receiveShadows: boolean = true; + + protected _shaderName: string = ''; + protected _materialType: MaterialType; + protected _renderTags = new Map(); + + constructor(config: T) { + super(config); + this._materialType = config.materialType; + this._initializeFromConfig(config); + } + + public setProperty(name: string, value: MaterialPropertyValue): void { + if (this._properties.get(name) !== value) { + this._properties.set(name, value); + this._markDirty(); + this._onPropertyChanged(name, value); + } + } + + public getProperty(name: string): TValue | null { + return (this._properties.get(name) as TValue) || null; + } + + public hasProperty(name: string): boolean { + return this._properties.has(name); + } + + public getPropertyNames(): string[] { + return Array.from(this._properties.keys()); + } + + public enableKeyword(keyword: string): void { + if (!this._keywords.has(keyword)) { + this._keywords.add(keyword); + this._markDirty(); + this._onKeywordChanged(keyword, true); + } + } + + public disableKeyword(keyword: string): void { + if (this._keywords.has(keyword)) { + this._keywords.delete(keyword); + this._markDirty(); + this._onKeywordChanged(keyword, false); + } + } + + public hasKeyword(keyword: string): boolean { + return this._keywords.has(keyword); + } + + public getKeywords(): string[] { + return Array.from(this._keywords); + } + + public get blendMode(): BlendMode { return this._blendMode; } + public set blendMode(value: BlendMode) { + if (this._blendMode !== value) { + this._blendMode = value; + this._markDirty(); + this._updateRenderQueue(); + } + } + + public get cullMode(): CullMode { return this._cullMode; } + public set cullMode(value: CullMode) { + if (this._cullMode !== value) { + this._cullMode = value; + this._markDirty(); + } + } + + public get depthTest(): DepthTest { return this._depthTest; } + public set depthTest(value: DepthTest) { + if (this._depthTest !== value) { + this._depthTest = value; + this._markDirty(); + } + } + + public get depthWrite(): boolean { return this._depthWrite; } + public set depthWrite(value: boolean) { + if (this._depthWrite !== value) { + this._depthWrite = value; + this._markDirty(); + } + } + + public get renderQueue(): number { return this._renderQueue; } + public set renderQueue(value: number) { + if (this._renderQueue !== value) { + this._renderQueue = Math.max(0, Math.min(5000, value)); + this._markDirty(); + } + } + + public get shadowCasting(): ShadowCasting { return this._shadowCasting; } + public set shadowCasting(value: ShadowCasting) { + if (this._shadowCasting !== value) { + this._shadowCasting = value; + this._markDirty(); + } + } + + public get receiveShadows(): boolean { return this._receiveShadows; } + public set receiveShadows(value: boolean) { + if (this._receiveShadows !== value) { + this._receiveShadows = value; + this._markDirty(); + } + } + + public get materialType(): MaterialType { return this._materialType; } + public get shaderName(): string { return this._shaderName; } + public get isDirty(): boolean { return this._isDirty; } + public get lastModified(): number { return this._lastModified; } + + public setRenderTag(key: string, value: string): void { + this._renderTags.set(key, value); + this._markDirty(); + } + + public getRenderTag(key: string): string | null { + return this._renderTags.get(key) || null; + } + + public hasRenderTag(key: string): boolean { + return this._renderTags.has(key); + } + + protected onInitialize(): void { + this._setupDefaultProperties(); + this._setupDefaultKeywords(); + this._setupDefaultRenderTags(); + } + + public onDestroy(): void { + this._properties.clear(); + this._keywords.clear(); + this._renderTags.clear(); + } + + protected abstract _setupDefaultProperties(): void; + protected abstract _setupDefaultKeywords(): void; + protected abstract _getAvailableProperties(): MaterialProperty[]; + protected abstract _getAvailableKeywords(): MaterialKeyword[]; + + protected _initializeFromConfig(config: T): void { + if (config.shaderName) this._shaderName = config.shaderName; + if (config.renderQueue !== undefined) this._renderQueue = config.renderQueue; + if (config.blendMode) this._blendMode = config.blendMode; + if (config.cullMode) this._cullMode = config.cullMode; + if (config.depthTest) this._depthTest = config.depthTest; + if (config.depthWrite !== undefined) this._depthWrite = config.depthWrite; + if (config.shadowCasting) this._shadowCasting = config.shadowCasting; + if (config.receiveShadows !== undefined) this._receiveShadows = config.receiveShadows; + + if (config.properties) { + for (const [name, value] of Object.entries(config.properties)) { + this._properties.set(name, value); + } + } + + if (config.keywords) { + for (const keyword of config.keywords) { + this._keywords.add(keyword); + } + } + + if (config.renderTags) { + for (const [key, value] of Object.entries(config.renderTags)) { + this._renderTags.set(key, value); + } + } + } + + protected _setupDefaultRenderTags(): void { + this._renderTags.set('RenderType', this._blendMode === BlendMode.OPAQUE ? 'Opaque' : 'Transparent'); + this._renderTags.set('Queue', this._getRenderQueueName()); + this._renderTags.set('IgnoreProjector', 'True'); + this._renderTags.set('ForceNoShadowCasting', this._shadowCasting === ShadowCasting.OFF ? 'True' : 'False'); + } + + protected _updateRenderQueue(): void { + + switch (this._blendMode) { + case BlendMode.OPAQUE: + if (this._renderQueue >= 2500) this._renderQueue = 2000; + break; + case BlendMode.ALPHA_TEST: + if (this._renderQueue < 2450 || this._renderQueue >= 2550) this._renderQueue = 2450; + break; + case BlendMode.ALPHA_BLEND: + case BlendMode.ADDITIVE: + case BlendMode.MULTIPLY: + case BlendMode.SCREEN: + case BlendMode.OVERLAY: + case BlendMode.SOFT_ADDITIVE: + case BlendMode.PREMULTIPLIED: + if (this._renderQueue < 3000) this._renderQueue = 3000; + break; + } + } + + protected _getRenderQueueName(): string { + if (this._renderQueue < 1000) return 'Background'; + if (this._renderQueue < 2000) return 'Geometry'; + if (this._renderQueue < 2500) return 'AlphaTest'; + if (this._renderQueue < 3000) return 'GeometryLast'; + if (this._renderQueue < 4000) return 'Transparent'; + return 'Overlay'; + } + + protected _markDirty(): void { + this._isDirty = true; + this._lastModified = performance.now(); + } + + protected _onPropertyChanged(name: string, value: MaterialPropertyValue): void { + + } + + protected _onKeywordChanged(keyword: string, enabled: boolean): void { + + } + + public setColor(propertyName: string, color: Vec3 | Vec4): void { + this.setProperty(propertyName, color); + } + + public getColor(propertyName: string): Vec4 | null { + const value = this.getProperty(propertyName); + if (!value) return null; + + if (typeof value === 'object' && 'x' in value && 'y' in value && 'z' in value && 'w' in value) { + return value as Vec4; + } + + if (typeof value === 'object' && 'x' in value && 'y' in value && 'z' in value) { + const vec3 = value as Vec3; + return { x: vec3.x, y: vec3.y, z: vec3.z, w: 1.0 }; + } + + return null; + } + + public setTexture(propertyName: string, texture: WebGLTexture | null): void { + this.setProperty(propertyName, texture); + + const keywordName = `${propertyName.toUpperCase()}_ON`; + if (texture) { + this.enableKeyword(keywordName); + } else { + this.disableKeyword(keywordName); + } + } + + public getTexture(propertyName: string): WebGLTexture | null { + return this.getProperty(propertyName); + } + + public setFloat(propertyName: string, value: number): void { + this.setProperty(propertyName, value); + } + + public getFloat(propertyName: string): number { + return this.getProperty(propertyName) || 0; + } + + public setVector(propertyName: string, vector: Vec2 | Vec3 | Vec4): void { + this.setProperty(propertyName, vector); + } + + public getVector(propertyName: string): Vec2 | Vec3 | Vec4 | null { + return this.getProperty(propertyName); + } + + public setMatrix(propertyName: string, matrix: Mat3 | Mat4): void { + this.setProperty(propertyName, matrix); + } + + public getMatrix(propertyName: string): Mat3 | Mat4 | null { + return this.getProperty(propertyName); + } + + public serialize(): Record { + return { + materialType: this._materialType, + shaderName: this._shaderName, + renderQueue: this._renderQueue, + blendMode: this._blendMode, + cullMode: this._cullMode, + depthTest: this._depthTest, + depthWrite: this._depthWrite, + shadowCasting: this._shadowCasting, + receiveShadows: this._receiveShadows, + properties: Object.fromEntries(this._properties), + keywords: Array.from(this._keywords), + renderTags: Object.fromEntries(this._renderTags) + }; + } + + public deserialize(data: Record): void { + if (data.materialType) this._materialType = data.materialType; + if (data.shaderName) this._shaderName = data.shaderName; + if (data.renderQueue !== undefined) this._renderQueue = data.renderQueue; + if (data.blendMode) this._blendMode = data.blendMode; + if (data.cullMode) this._cullMode = data.cullMode; + if (data.depthTest) this._depthTest = data.depthTest; + if (data.depthWrite !== undefined) this._depthWrite = data.depthWrite; + if (data.shadowCasting) this._shadowCasting = data.shadowCasting; + if (data.receiveShadows !== undefined) this._receiveShadows = data.receiveShadows; + + if (data.properties) { + this._properties.clear(); + for (const [name, value] of Object.entries(data.properties)) { + this._properties.set(name, value as MaterialPropertyValue); + } + } + + if (data.keywords) { + this._keywords.clear(); + for (const keyword of data.keywords) { + this._keywords.add(keyword); + } + } + + if (data.renderTags) { + this._renderTags.clear(); + for (const [key, value] of Object.entries(data.renderTags)) { + this._renderTags.set(key, value as string); + } + } + + this._markDirty(); + } + + public clone(): this { + const serialized = this.serialize(); + const cloned = new (this.constructor as any)(serialized); + cloned.deserialize(serialized); + return cloned; + } +} diff --git a/packages/core/src/renderer/webgl2/material/index.ts b/packages/core/src/renderer/webgl2/material/index.ts new file mode 100644 index 0000000..d3c3ade --- /dev/null +++ b/packages/core/src/renderer/webgl2/material/index.ts @@ -0,0 +1,36 @@ +export * from './base-material'; + +export * from './standard-material'; +export * from './pbr-material'; + +export * from './material-manager'; + +export type { + MaterialPropertyValue, + MaterialProperty, + MaterialKeyword, + MaterialConfig, + StandardMaterialConfig, + PBRMaterialConfig, + UnlitMaterialConfig +} from './base-material'; + +export { + MaterialType, + BlendMode, + CullMode, + DepthTest, + ShadowCasting, + LightMode +} from './base-material'; + +export { StandardMaterialComponent as StandardMaterial } from './standard-material'; +export { PBRMaterialComponent as PBRMaterial } from './pbr-material'; +export { materialManager } from './material-manager'; + +export { + createMaterial, + getMaterial, + destroyMaterial, + cloneMaterial +} from './material-manager'; \ No newline at end of file diff --git a/packages/core/src/renderer/webgl2/material/material-manager.ts b/packages/core/src/renderer/webgl2/material/material-manager.ts new file mode 100644 index 0000000..37ef8dd --- /dev/null +++ b/packages/core/src/renderer/webgl2/material/material-manager.ts @@ -0,0 +1,433 @@ +import { + BaseMaterialComponent, + MaterialType, + MaterialConfig, + MaterialPropertyValue +} from './base-material'; +import { StandardMaterialComponent } from './standard-material'; +import { PBRMaterialComponent } from './pbr-material'; + +export type MaterialConstructor = new (config: T) => BaseMaterialComponent; + +export interface MaterialFactoryEntry { + readonly type: MaterialType; + readonly constructor: MaterialConstructor; + readonly defaultConfig: MaterialConfig; + readonly description: string; +} + +export interface MaterialEvents { + materialCreated: { material: BaseMaterialComponent; type: MaterialType }; + materialDestroyed: { materialId: string; type: MaterialType }; + materialModified: { material: BaseMaterialComponent; property: string; value: MaterialPropertyValue }; + materialCloned: { original: BaseMaterialComponent; clone: BaseMaterialComponent }; +} + +export type MaterialEventListener = ( + event: MaterialEvents[T] +) => void; + +export class MaterialManager { + private static _instance: MaterialManager | null = null; + private readonly _materials = new Map(); + private readonly _materialsByType = new Map>(); + private readonly _factory = new Map(); + private readonly _eventListeners = new Map>(); + private readonly _resourceTracker = new Map(); + private readonly _cacheTimeout: number = 60000; + private readonly _cleanupInterval: NodeJS.Timeout; + + private constructor() { + this._registerBuiltInMaterials(); + this._cleanupInterval = setInterval(() => this._performCleanup(), this._cacheTimeout); + } + + public static getInstance(): MaterialManager { + if (!MaterialManager._instance) { + MaterialManager._instance = new MaterialManager(); + } + return MaterialManager._instance; + } + + public static destroy(): void { + if (MaterialManager._instance) { + MaterialManager._instance._cleanup(); + MaterialManager._instance = null; + } + } + + public registerMaterialType( + type: MaterialType, + constructor: MaterialConstructor, + defaultConfig: T, + description: string = '' + ): void { + if (this._factory.has(type)) { + console.warn(`Material type ${type} is already registered. Overwriting...`); + } + + this._factory.set(type, { + type, + constructor: constructor as MaterialConstructor, + defaultConfig, + description + }); + + console.log(`✅ Material type '${type}' registered successfully`); + } + + public unregisterMaterialType(type: MaterialType): boolean { + if (!this._factory.has(type)) { + console.warn(`Material type ${type} is not registered`); + return false; + } + + const materials = this._materialsByType.get(type); + if (materials) { + for (const material of materials) { + this.destroyMaterial(material.id); + } + } + + this._factory.delete(type); + this._materialsByType.delete(type); + + console.log(`✅ Material type '${type}' unregistered successfully`); + return true; + } + + public getRegisteredTypes(): MaterialType[] { + return Array.from(this._factory.keys()); + } + + public getTypeInfo(type: MaterialType): MaterialFactoryEntry | null { + return this._factory.get(type) || null; + } + + public createMaterial( + type: MaterialType, + config: Partial = {} + ): BaseMaterialComponent | null { + const factoryEntry = this._factory.get(type); + if (!factoryEntry) { + console.error(`Material type ${type} is not registered`); + return null; + } + + try { + + const finalConfig = { + ...factoryEntry.defaultConfig, + ...config, + materialType: type + } as T; + + const material = new factoryEntry.constructor(finalConfig) as BaseMaterialComponent; + + this._materials.set(material.id, material); + + if (!this._materialsByType.has(type)) { + this._materialsByType.set(type, new Set()); + } + this._materialsByType.get(type)!.add(material); + + this._resourceTracker.set(material.id, 1); + + this._emitEvent('materialCreated', { material, type }); + + console.log(`✅ Material '${material.id}' of type '${type}' created successfully`); + return material; + + } catch (error) { + console.error(`❌ Failed to create material of type ${type}:`, error); + return null; + } + } + + public getMaterial(id: string): BaseMaterialComponent | null { + return this._materials.get(id) || null; + } + + public getAllMaterials(): BaseMaterialComponent[] { + return Array.from(this._materials.values()); + } + + public getMaterialsByType(type: MaterialType): BaseMaterialComponent[] { + const materials = this._materialsByType.get(type); + return materials ? Array.from(materials) : []; + } + + public destroyMaterial(id: string): boolean { + const material = this._materials.get(id); + if (!material) { + console.warn(`Material ${id} not found`); + return false; + } + + try { + + const refCount = this._resourceTracker.get(id) || 0; + if (refCount > 1) { + console.warn(`Material ${id} still has ${refCount} references. Force destroying...`); + } + + this._materials.delete(id); + this._resourceTracker.delete(id); + + const typeSet = this._materialsByType.get(material.materialType); + if (typeSet) { + typeSet.delete(material); + if (typeSet.size === 0) { + this._materialsByType.delete(material.materialType); + } + } + + material.onDestroy(); + + this._emitEvent('materialDestroyed', { + materialId: id, + type: material.materialType + }); + + console.log(`✅ Material '${id}' destroyed successfully`); + return true; + + } catch (error) { + console.error(`❌ Failed to destroy material ${id}:`, error); + return false; + } + } + + public cloneMaterial(id: string, newId?: string): BaseMaterialComponent | null { + const original = this._materials.get(id); + if (!original) { + console.error(`Material ${id} not found for cloning`); + return null; + } + + try { + + const clone = original.clone(); + + if (newId) { + + (clone as any)._id = newId; + } + + this._materials.set(clone.id, clone); + + const typeSet = this._materialsByType.get(clone.materialType); + if (typeSet) { + typeSet.add(clone); + } + + this._resourceTracker.set(clone.id, 1); + + this._emitEvent('materialCloned', { original, clone }); + + console.log(`✅ Material '${id}' cloned successfully as '${clone.id}'`); + return clone; + + } catch (error) { + console.error(`❌ Failed to clone material ${id}:`, error); + return null; + } + } + + public addReference(id: string): boolean { + if (!this._materials.has(id)) { + console.warn(`Cannot add reference to non-existent material ${id}`); + return false; + } + + const currentCount = this._resourceTracker.get(id) || 0; + this._resourceTracker.set(id, currentCount + 1); + return true; + } + + public removeReference(id: string): boolean { + if (!this._materials.has(id)) { + console.warn(`Cannot remove reference from non-existent material ${id}`); + return false; + } + + const currentCount = this._resourceTracker.get(id) || 0; + if (currentCount <= 1) { + + console.log(`Material ${id} has no more references - eligible for cleanup`); + this._resourceTracker.set(id, 0); + return false; + } + + this._resourceTracker.set(id, currentCount - 1); + return true; + } + + public getReferenceCount(id: string): number { + return this._resourceTracker.get(id) || 0; + } + + public addEventListener( + event: T, + listener: MaterialEventListener + ): void { + if (!this._eventListeners.has(event)) { + this._eventListeners.set(event, new Set()); + } + this._eventListeners.get(event)!.add(listener as MaterialEventListener); + } + + public removeEventListener( + event: T, + listener: MaterialEventListener + ): void { + const listeners = this._eventListeners.get(event); + if (listeners) { + listeners.delete(listener as MaterialEventListener); + } + } + + private _emitEvent( + event: T, + data: MaterialEvents[T] + ): void { + const listeners = this._eventListeners.get(event); + if (listeners) { + for (const listener of listeners) { + try { + listener(data); + } catch (error) { + console.error(`Error in material event listener for ${event}:`, error); + } + } + } + } + + public getStatistics(): { + totalMaterials: number; + materialsByType: Record; + totalReferences: number; + memoryUsage: number; + } { + const materialsByType: Record = {} as any; + + for (const [type, materials] of this._materialsByType) { + materialsByType[type] = materials.size; + } + + const totalReferences = Array.from(this._resourceTracker.values()) + .reduce((sum, count) => sum + count, 0); + + const memoryUsage = this._materials.size * 1024; + + return { + totalMaterials: this._materials.size, + materialsByType, + totalReferences, + memoryUsage + }; + } + + public findMaterialsByProperty( + propertyName: string, + value?: MaterialPropertyValue + ): BaseMaterialComponent[] { + const results: BaseMaterialComponent[] = []; + + for (const material of this._materials.values()) { + if (material.hasProperty(propertyName)) { + if (value === undefined || material.getProperty(propertyName) === value) { + results.push(material); + } + } + } + + return results; + } + + public findMaterialsByKeyword(keyword: string): BaseMaterialComponent[] { + const results: BaseMaterialComponent[] = []; + + for (const material of this._materials.values()) { + if (material.hasKeyword(keyword)) { + results.push(material); + } + } + + return results; + } + + private _registerBuiltInMaterials(): void { + + this.registerMaterialType( + MaterialType.STANDARD, + StandardMaterialComponent, + { materialType: MaterialType.STANDARD }, + 'Unity-style Standard PBR Material' + ); + + this.registerMaterialType( + MaterialType.PBR, + PBRMaterialComponent, + { materialType: MaterialType.PBR }, + 'glTF 2.0 compatible PBR Material' + ); + + console.log('✅ Built-in material types registered'); + } + + private _performCleanup(): void { + let cleanedCount = 0; + + for (const [id, refCount] of this._resourceTracker) { + if (refCount === 0) { + this.destroyMaterial(id); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + console.log(`🧹 Cleaned up ${cleanedCount} unused materials`); + } + } + + private _cleanup(): void { + + if (this._cleanupInterval) { + clearInterval(this._cleanupInterval); + } + + for (const id of this._materials.keys()) { + this.destroyMaterial(id); + } + + this._materials.clear(); + this._materialsByType.clear(); + this._factory.clear(); + this._eventListeners.clear(); + this._resourceTracker.clear(); + + console.log('✅ Material Manager cleaned up'); + } +} + +export function createMaterial( + type: MaterialType, + config: Partial = {} +): BaseMaterialComponent | null { + return MaterialManager.getInstance().createMaterial(type, config); +} + +export function getMaterial(id: string): BaseMaterialComponent | null { + return MaterialManager.getInstance().getMaterial(id); +} + +export function destroyMaterial(id: string): boolean { + return MaterialManager.getInstance().destroyMaterial(id); +} + +export function cloneMaterial(id: string, newId?: string): BaseMaterialComponent | null { + return MaterialManager.getInstance().cloneMaterial(id, newId); +} + +export const materialManager = MaterialManager.getInstance(); \ No newline at end of file diff --git a/packages/core/src/renderer/webgl2/material/pbr-material.ts b/packages/core/src/renderer/webgl2/material/pbr-material.ts new file mode 100644 index 0000000..256c9aa --- /dev/null +++ b/packages/core/src/renderer/webgl2/material/pbr-material.ts @@ -0,0 +1,579 @@ +import { + BaseMaterialComponent, + MaterialType, + MaterialProperty, + MaterialKeyword, + PBRMaterialConfig, + Vec2, + Vec3, + Vec4, + BlendMode, + CullMode, + ShadowCasting +} from './base-material'; + +const PBR_PROPERTIES: MaterialProperty[] = [ + + { + name: '_BaseColorFactor', + displayName: 'Base Color', + type: 'color', + defaultValue: { x: 1, y: 1, z: 1, w: 1 }, + category: 'PBR', + tooltip: 'Base color of the material' + }, + { + name: '_BaseColorTexture', + displayName: 'Base Color Texture', + type: 'texture', + defaultValue: null, + category: 'PBR', + tooltip: 'Base color texture (sRGB)' + }, + + { + name: '_MetallicFactor', + displayName: 'Metallic Factor', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 1 }, + category: 'PBR', + tooltip: 'Metallic factor' + }, + { + name: '_RoughnessFactor', + displayName: 'Roughness Factor', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 1 }, + category: 'PBR', + tooltip: 'Roughness factor' + }, + { + name: '_MetallicRoughnessTexture', + displayName: 'Metallic-Roughness Texture', + type: 'texture', + defaultValue: null, + category: 'PBR', + tooltip: 'Metallic (B) and Roughness (G) texture' + }, + + { + name: '_NormalTexture', + displayName: 'Normal Texture', + type: 'texture', + defaultValue: null, + category: 'Normal', + tooltip: 'Normal map texture' + }, + { + name: '_NormalScale', + displayName: 'Normal Scale', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 2 }, + category: 'Normal', + tooltip: 'Normal map scale factor' + }, + + { + name: '_OcclusionTexture', + displayName: 'Occlusion Texture', + type: 'texture', + defaultValue: null, + category: 'Occlusion', + tooltip: 'Ambient occlusion texture (R)' + }, + { + name: '_OcclusionStrength', + displayName: 'Occlusion Strength', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 1 }, + category: 'Occlusion', + tooltip: 'Occlusion effect strength' + }, + + { + name: '_EmissiveTexture', + displayName: 'Emissive Texture', + type: 'texture', + defaultValue: null, + category: 'Emission', + tooltip: 'Emissive texture (sRGB)' + }, + { + name: '_EmissiveFactor', + displayName: 'Emissive Factor', + type: 'color', + defaultValue: { x: 0, y: 0, z: 0, w: 1 }, + category: 'Emission', + tooltip: 'Emissive color factor' + }, + + { + name: '_AlphaMode', + displayName: 'Alpha Mode', + type: 'int', + defaultValue: 0, + category: 'Alpha', + tooltip: 'Alpha rendering mode: 0=OPAQUE, 1=MASK, 2=BLEND' + }, + { + name: '_AlphaCutoff', + displayName: 'Alpha Cutoff', + type: 'float', + defaultValue: 0.5, + range: { min: 0, max: 1 }, + category: 'Alpha', + tooltip: 'Alpha cutoff threshold for MASK mode' + }, + + { + name: '_DoubleSided', + displayName: 'Double Sided', + type: 'bool', + defaultValue: false, + category: 'Rendering', + tooltip: 'Enable double-sided rendering' + }, + + { + name: '_BaseColorTexture_ST', + displayName: 'Base Color Transform', + type: 'vec4', + defaultValue: { x: 1, y: 1, z: 0, w: 0 }, + category: 'Texture Transforms', + tooltip: 'Base color texture transform' + }, + { + name: '_MetallicRoughnessTexture_ST', + displayName: 'Metallic-Roughness Transform', + type: 'vec4', + defaultValue: { x: 1, y: 1, z: 0, w: 0 }, + category: 'Texture Transforms', + tooltip: 'Metallic-roughness texture transform' + }, + { + name: '_NormalTexture_ST', + displayName: 'Normal Transform', + type: 'vec4', + defaultValue: { x: 1, y: 1, z: 0, w: 0 }, + category: 'Texture Transforms', + tooltip: 'Normal texture transform' + }, + { + name: '_OcclusionTexture_ST', + displayName: 'Occlusion Transform', + type: 'vec4', + defaultValue: { x: 1, y: 1, z: 0, w: 0 }, + category: 'Texture Transforms', + tooltip: 'Occlusion texture transform' + }, + { + name: '_EmissiveTexture_ST', + displayName: 'Emissive Transform', + type: 'vec4', + defaultValue: { x: 1, y: 1, z: 0, w: 0 }, + category: 'Texture Transforms', + tooltip: 'Emissive texture transform' + } +]; + +const PBR_KEYWORDS: MaterialKeyword[] = [ + + { + name: '_BASECOLORTEXTURE', + displayName: 'Base Color Texture', + description: 'Enable base color texture', + category: 'Textures' + }, + { + name: '_METALLICROUGHNESSTEXTURE', + displayName: 'Metallic-Roughness Texture', + description: 'Enable metallic-roughness texture', + category: 'Textures' + }, + { + name: '_NORMALTEXTURE', + displayName: 'Normal Texture', + description: 'Enable normal mapping', + category: 'Textures' + }, + { + name: '_OCCLUSIONTEXTURE', + displayName: 'Occlusion Texture', + description: 'Enable occlusion mapping', + category: 'Textures' + }, + { + name: '_EMISSIVETEXTURE', + displayName: 'Emissive Texture', + description: 'Enable emissive texture', + category: 'Textures' + }, + + { + name: '_ALPHAMODE_OPAQUE', + displayName: 'Alpha Mode Opaque', + description: 'Opaque alpha mode', + category: 'Alpha', + mutuallyExclusive: ['_ALPHAMODE_MASK', '_ALPHAMODE_BLEND'] + }, + { + name: '_ALPHAMODE_MASK', + displayName: 'Alpha Mode Mask', + description: 'Alpha mask mode', + category: 'Alpha', + mutuallyExclusive: ['_ALPHAMODE_OPAQUE', '_ALPHAMODE_BLEND'] + }, + { + name: '_ALPHAMODE_BLEND', + displayName: 'Alpha Mode Blend', + description: 'Alpha blend mode', + category: 'Alpha', + mutuallyExclusive: ['_ALPHAMODE_OPAQUE', '_ALPHAMODE_MASK'] + }, + + { + name: '_DOUBLESIDED', + displayName: 'Double Sided', + description: 'Enable double-sided rendering', + category: 'Rendering' + } +]; + +export class PBRMaterialComponent extends BaseMaterialComponent { + constructor(config: PBRMaterialConfig = { materialType: MaterialType.PBR }) { + super({ + shaderName: 'PBR', + renderQueue: 2000, + blendMode: BlendMode.OPAQUE, + cullMode: CullMode.BACK, + shadowCasting: ShadowCasting.ON, + receiveShadows: true, + ...config + }); + } + + get baseColor(): Vec4 { + return this.getProperty('_BaseColorFactor') || { x: 1, y: 1, z: 1, w: 1 }; + } + + set baseColor(value: Vec4) { + this.setProperty('_BaseColorFactor', value); + } + + get baseColorTexture(): WebGLTexture | null { + return this.getProperty('_BaseColorTexture'); + } + + set baseColorTexture(value: WebGLTexture | null) { + this.setTexture('_BaseColorTexture', value); + if (value) { + this.enableKeyword('_BASECOLORTEXTURE'); + } else { + this.disableKeyword('_BASECOLORTEXTURE'); + } + } + + get metallicFactor(): number { + return this.getProperty('_MetallicFactor') || 1; + } + + set metallicFactor(value: number) { + this.setProperty('_MetallicFactor', Math.max(0, Math.min(1, value))); + } + + get roughnessFactor(): number { + return this.getProperty('_RoughnessFactor') || 1; + } + + set roughnessFactor(value: number) { + this.setProperty('_RoughnessFactor', Math.max(0, Math.min(1, value))); + } + + get metallicRoughnessTexture(): WebGLTexture | null { + return this.getProperty('_MetallicRoughnessTexture'); + } + + set metallicRoughnessTexture(value: WebGLTexture | null) { + this.setTexture('_MetallicRoughnessTexture', value); + if (value) { + this.enableKeyword('_METALLICROUGHNESSTEXTURE'); + } else { + this.disableKeyword('_METALLICROUGHNESSTEXTURE'); + } + } + + get normalTexture(): WebGLTexture | null { + return this.getProperty('_NormalTexture'); + } + + set normalTexture(value: WebGLTexture | null) { + this.setTexture('_NormalTexture', value); + if (value) { + this.enableKeyword('_NORMALTEXTURE'); + } else { + this.disableKeyword('_NORMALTEXTURE'); + } + } + + get normalScale(): number { + return this.getProperty('_NormalScale') || 1; + } + + set normalScale(value: number) { + this.setProperty('_NormalScale', Math.max(0, Math.min(2, value))); + } + + get occlusionTexture(): WebGLTexture | null { + return this.getProperty('_OcclusionTexture'); + } + + set occlusionTexture(value: WebGLTexture | null) { + this.setTexture('_OcclusionTexture', value); + if (value) { + this.enableKeyword('_OCCLUSIONTEXTURE'); + } else { + this.disableKeyword('_OCCLUSIONTEXTURE'); + } + } + + get occlusionStrength(): number { + return this.getProperty('_OcclusionStrength') || 1; + } + + set occlusionStrength(value: number) { + this.setProperty('_OcclusionStrength', Math.max(0, Math.min(1, value))); + } + + get emissiveTexture(): WebGLTexture | null { + return this.getProperty('_EmissiveTexture'); + } + + set emissiveTexture(value: WebGLTexture | null) { + this.setTexture('_EmissiveTexture', value); + if (value) { + this.enableKeyword('_EMISSIVETEXTURE'); + } else { + this.disableKeyword('_EMISSIVETEXTURE'); + } + } + + get emissiveFactor(): Vec3 { + const color = this.getProperty('_EmissiveFactor'); + if (!color) return { x: 0, y: 0, z: 0 }; + return { x: color.x, y: color.y, z: color.z }; + } + + set emissiveFactor(value: Vec3) { + this.setProperty('_EmissiveFactor', { x: value.x, y: value.y, z: value.z, w: 1 }); + } + + get alphaMode(): 'OPAQUE' | 'MASK' | 'BLEND' { + const mode = this.getProperty('_AlphaMode') || 0; + switch (mode) { + case 1: return 'MASK'; + case 2: return 'BLEND'; + default: return 'OPAQUE'; + } + } + + set alphaMode(value: 'OPAQUE' | 'MASK' | 'BLEND') { + let mode = 0; + + this.disableKeyword('_ALPHAMODE_OPAQUE'); + this.disableKeyword('_ALPHAMODE_MASK'); + this.disableKeyword('_ALPHAMODE_BLEND'); + + switch (value) { + case 'MASK': + mode = 1; + this.blendMode = BlendMode.ALPHA_TEST; + this.enableKeyword('_ALPHAMODE_MASK'); + this.renderQueue = 2450; + break; + case 'BLEND': + mode = 2; + this.blendMode = BlendMode.ALPHA_BLEND; + this.enableKeyword('_ALPHAMODE_BLEND'); + this.renderQueue = 3000; + break; + default: + mode = 0; + this.blendMode = BlendMode.OPAQUE; + this.enableKeyword('_ALPHAMODE_OPAQUE'); + this.renderQueue = 2000; + break; + } + this.setProperty('_AlphaMode', mode); + } + + get alphaCutoff(): number { + return this.getProperty('_AlphaCutoff') || 0.5; + } + + set alphaCutoff(value: number) { + this.setProperty('_AlphaCutoff', Math.max(0, Math.min(1, value))); + } + + get doubleSided(): boolean { + return this.getProperty('_DoubleSided') || false; + } + + set doubleSided(value: boolean) { + this.setProperty('_DoubleSided', value); + if (value) { + this.cullMode = CullMode.NONE; + this.enableKeyword('_DOUBLESIDED'); + } else { + this.cullMode = CullMode.BACK; + this.disableKeyword('_DOUBLESIDED'); + } + } + + public setTextureTransform(textureName: string, scale: Vec2, offset: Vec2): void { + const propName = `${textureName}_ST`; + this.setProperty(propName, { x: scale.x, y: scale.y, z: offset.x, w: offset.y }); + } + + public getTextureScale(textureName: string): Vec2 { + const st = this.getProperty(`${textureName}_ST`); + return st ? { x: st.x, y: st.y } : { x: 1, y: 1 }; + } + + public getTextureOffset(textureName: string): Vec2 { + const st = this.getProperty(`${textureName}_ST`); + return st ? { x: st.z, y: st.w } : { x: 0, y: 0 }; + } + + protected _setupDefaultProperties(): void { + for (const prop of PBR_PROPERTIES) { + if (!this.hasProperty(prop.name)) { + this.setProperty(prop.name, prop.defaultValue); + } + } + } + + protected _setupDefaultKeywords(): void { + + this.enableKeyword('_ALPHAMODE_OPAQUE'); + + if (this.baseColorTexture) this.enableKeyword('_BASECOLORTEXTURE'); + if (this.metallicRoughnessTexture) this.enableKeyword('_METALLICROUGHNESSTEXTURE'); + if (this.normalTexture) this.enableKeyword('_NORMALTEXTURE'); + if (this.occlusionTexture) this.enableKeyword('_OCCLUSIONTEXTURE'); + if (this.emissiveTexture) this.enableKeyword('_EMISSIVETEXTURE'); + if (this.doubleSided) this.enableKeyword('_DOUBLESIDED'); + } + + protected _getAvailableProperties(): MaterialProperty[] { + return PBR_PROPERTIES; + } + + protected _getAvailableKeywords(): MaterialKeyword[] { + return PBR_KEYWORDS; + } + + protected _onPropertyChanged(name: string, value: any): void { + super._onPropertyChanged(name, value); + + switch (name) { + case '_EmissiveFactor': + const color = value as Vec4; + if (color && (color.x > 0 || color.y > 0 || color.z > 0)) { + + } + break; + case '_DoubleSided': + if (value) { + this.cullMode = CullMode.NONE; + this.enableKeyword('_DOUBLESIDED'); + } else { + this.cullMode = CullMode.BACK; + this.disableKeyword('_DOUBLESIDED'); + } + break; + } + } + + public copyFromGLTF(gltfMaterial: any): void { + if (!gltfMaterial) return; + + if (gltfMaterial.pbrMetallicRoughness) { + const pbr = gltfMaterial.pbrMetallicRoughness; + + if (pbr.baseColorFactor) { + this.baseColor = { + x: pbr.baseColorFactor[0] || 1, + y: pbr.baseColorFactor[1] || 1, + z: pbr.baseColorFactor[2] || 1, + w: pbr.baseColorFactor[3] || 1 + }; + } + + if (pbr.metallicFactor !== undefined) { + this.metallicFactor = pbr.metallicFactor; + } + + if (pbr.roughnessFactor !== undefined) { + this.roughnessFactor = pbr.roughnessFactor; + } + } + + if (gltfMaterial.normalTexture) { + if (gltfMaterial.normalTexture.scale !== undefined) { + this.normalScale = gltfMaterial.normalTexture.scale; + } + } + + if (gltfMaterial.occlusionTexture) { + if (gltfMaterial.occlusionTexture.strength !== undefined) { + this.occlusionStrength = gltfMaterial.occlusionTexture.strength; + } + } + + if (gltfMaterial.emissiveFactor) { + this.emissiveFactor = { + x: gltfMaterial.emissiveFactor[0] || 0, + y: gltfMaterial.emissiveFactor[1] || 0, + z: gltfMaterial.emissiveFactor[2] || 0 + }; + } + + if (gltfMaterial.alphaMode) { + this.alphaMode = gltfMaterial.alphaMode as 'OPAQUE' | 'MASK' | 'BLEND'; + } + + if (gltfMaterial.alphaCutoff !== undefined) { + this.alphaCutoff = gltfMaterial.alphaCutoff; + } + + if (gltfMaterial.doubleSided !== undefined) { + this.doubleSided = gltfMaterial.doubleSided; + } + } + + public exportToGLTF(): any { + return { + name: this.constructor.name, + pbrMetallicRoughness: { + baseColorFactor: [this.baseColor.x, this.baseColor.y, this.baseColor.z, this.baseColor.w], + metallicFactor: this.metallicFactor, + roughnessFactor: this.roughnessFactor + }, + normalTexture: this.normalTexture ? { + scale: this.normalScale + } : undefined, + occlusionTexture: this.occlusionTexture ? { + strength: this.occlusionStrength + } : undefined, + emissiveTexture: this.emissiveTexture ? {} : undefined, + emissiveFactor: [this.emissiveFactor.x, this.emissiveFactor.y, this.emissiveFactor.z], + alphaMode: this.alphaMode, + alphaCutoff: this.alphaMode === 'MASK' ? this.alphaCutoff : undefined, + doubleSided: this.doubleSided || undefined + }; + } +} \ No newline at end of file diff --git a/packages/core/src/renderer/webgl2/material/standard-material.ts b/packages/core/src/renderer/webgl2/material/standard-material.ts new file mode 100644 index 0000000..7328f66 --- /dev/null +++ b/packages/core/src/renderer/webgl2/material/standard-material.ts @@ -0,0 +1,618 @@ +import { + BaseMaterialComponent, + MaterialType, + MaterialProperty, + MaterialKeyword, + StandardMaterialConfig, + Vec2, + Vec3, + Vec4, + BlendMode, + CullMode, + ShadowCasting +} from './base-material'; + +const STANDARD_PROPERTIES: MaterialProperty[] = [ + + { + name: '_MainTex', + displayName: 'Albedo', + type: 'texture', + defaultValue: null, + category: 'Main Maps', + tooltip: 'Albedo (RGB) and Transparency (A)' + }, + { + name: '_Color', + displayName: 'Color', + type: 'color', + defaultValue: { x: 1, y: 1, z: 1, w: 1 }, + category: 'Main Maps', + tooltip: 'Main color tint' + }, + + { + name: '_MetallicGlossMap', + displayName: 'Metallic', + type: 'texture', + defaultValue: null, + category: 'Main Maps', + tooltip: 'Metallic (R) and Smoothness (A)' + }, + { + name: '_Metallic', + displayName: 'Metallic', + type: 'float', + defaultValue: 0.0, + range: { min: 0, max: 1 }, + category: 'Main Maps', + tooltip: 'How metallic the surface is' + }, + { + name: '_Glossiness', + displayName: 'Smoothness', + type: 'float', + defaultValue: 0.5, + range: { min: 0, max: 1 }, + category: 'Main Maps', + tooltip: 'How smooth the surface is' + }, + { + name: '_GlossMapScale', + displayName: 'Smoothness Scale', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 1 }, + category: 'Main Maps', + tooltip: 'Scale for smoothness from texture' + }, + { + name: '_SmoothnessTextureChannel', + displayName: 'Source', + type: 'int', + defaultValue: 0, + category: 'Main Maps', + tooltip: 'Smoothness texture channel' + }, + + { + name: '_BumpMap', + displayName: 'Normal Map', + type: 'texture', + defaultValue: null, + category: 'Secondary Maps', + tooltip: 'Normal Map' + }, + { + name: '_BumpScale', + displayName: 'Normal Map Scale', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 2 }, + category: 'Secondary Maps', + tooltip: 'Strength of normal map effect' + }, + + { + name: '_ParallaxMap', + displayName: 'Height Map', + type: 'texture', + defaultValue: null, + category: 'Secondary Maps', + tooltip: 'Height Map (G)' + }, + { + name: '_Parallax', + displayName: 'Height Scale', + type: 'float', + defaultValue: 0.02, + range: { min: 0.005, max: 0.08 }, + category: 'Secondary Maps', + tooltip: 'Height map parallax depth' + }, + + { + name: '_OcclusionMap', + displayName: 'Occlusion', + type: 'texture', + defaultValue: null, + category: 'Secondary Maps', + tooltip: 'Occlusion (G)' + }, + { + name: '_OcclusionStrength', + displayName: 'Strength', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 1 }, + category: 'Secondary Maps', + tooltip: 'Occlusion effect strength' + }, + + { + name: '_EmissionMap', + displayName: 'Emission', + type: 'texture', + defaultValue: null, + category: 'Emission', + tooltip: 'Emission (RGB)' + }, + { + name: '_EmissionColor', + displayName: 'Color', + type: 'color', + defaultValue: { x: 0, y: 0, z: 0, w: 1 }, + category: 'Emission', + tooltip: 'Emission color' + }, + { + name: '_EmissionIntensity', + displayName: 'Intensity', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 10 }, + category: 'Emission', + tooltip: 'Emission intensity multiplier' + }, + + { + name: '_DetailMask', + displayName: 'Detail Mask', + type: 'texture', + defaultValue: null, + category: 'Detail Maps', + tooltip: 'Detail Mask (A)' + }, + { + name: '_DetailAlbedoMap', + displayName: 'Detail Albedo x2', + type: 'texture', + defaultValue: null, + category: 'Detail Maps', + tooltip: 'Detail Albedo x2' + }, + { + name: '_DetailNormalMap', + displayName: 'Detail Normal Map', + type: 'texture', + defaultValue: null, + category: 'Detail Maps', + tooltip: 'Detail Normal Map' + }, + { + name: '_DetailNormalMapScale', + displayName: 'Scale', + type: 'float', + defaultValue: 1.0, + range: { min: 0, max: 2 }, + category: 'Detail Maps', + tooltip: 'Detail normal map scale' + }, + + { + name: '_MainTex_ST', + displayName: 'Tiling', + type: 'vec4', + defaultValue: { x: 1, y: 1, z: 0, w: 0 }, + category: 'Tiling & Offset', + tooltip: 'Texture tiling (XY) and offset (ZW)' + }, + { + name: '_DetailAlbedoMap_ST', + displayName: 'Detail Tiling', + type: 'vec4', + defaultValue: { x: 1, y: 1, z: 0, w: 0 }, + category: 'Tiling & Offset', + tooltip: 'Detail texture tiling and offset' + }, + + { + name: '_UVSec', + displayName: 'UV Set for secondary textures', + type: 'int', + defaultValue: 0, + category: 'Advanced Options', + tooltip: 'UV channel for detail maps' + }, + { + name: '_Mode', + displayName: 'Rendering Mode', + type: 'int', + defaultValue: 0, + category: 'Advanced Options', + tooltip: 'Rendering mode' + }, + { + name: '_Cutoff', + displayName: 'Alpha Cutoff', + type: 'float', + defaultValue: 0.5, + range: { min: 0, max: 1 }, + category: 'Advanced Options', + tooltip: 'Alpha cutoff threshold' + } +]; + +const STANDARD_KEYWORDS: MaterialKeyword[] = [ + + { + name: '_NORMALMAP', + displayName: 'Normal Map', + description: 'Enable normal mapping', + category: 'Textures' + }, + { + name: '_METALLICGLOSSMAP', + displayName: 'Metallic Gloss Map', + description: 'Use metallic gloss map', + category: 'Textures' + }, + { + name: '_SPECGLOSSMAP', + displayName: 'Specular Gloss Map', + description: 'Use specular gloss map', + category: 'Textures' + }, + { + name: '_PARALLAXMAP', + displayName: 'Parallax Map', + description: 'Enable parallax mapping', + category: 'Textures' + }, + { + name: '_OCCLUSIONMAP', + displayName: 'Occlusion Map', + description: 'Enable occlusion mapping', + category: 'Textures' + }, + { + name: '_EMISSION', + displayName: 'Emission', + description: 'Enable emission', + category: 'Textures' + }, + { + name: '_DETAIL_MULX2', + displayName: 'Detail Multiply', + description: 'Enable detail multiply', + category: 'Detail' + }, + { + name: '_SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A', + displayName: 'Smoothness Source Albedo', + description: 'Smoothness from albedo alpha', + category: 'Advanced', + mutuallyExclusive: ['_SMOOTHNESS_TEXTURE_METALLIC_CHANNEL_A'] + }, + { + name: '_SMOOTHNESS_TEXTURE_METALLIC_CHANNEL_A', + displayName: 'Smoothness Source Metallic', + description: 'Smoothness from metallic alpha', + category: 'Advanced', + mutuallyExclusive: ['_SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A'] + }, + + { + name: '_ALPHATEST_ON', + displayName: 'Alpha Test', + description: 'Enable alpha testing', + category: 'Rendering', + mutuallyExclusive: ['_ALPHABLEND_ON', '_ALPHAPREMULTIPLY_ON'] + }, + { + name: '_ALPHABLEND_ON', + displayName: 'Alpha Blend', + description: 'Enable alpha blending', + category: 'Rendering', + mutuallyExclusive: ['_ALPHATEST_ON', '_ALPHAPREMULTIPLY_ON'] + }, + { + name: '_ALPHAPREMULTIPLY_ON', + displayName: 'Alpha Premultiply', + description: 'Enable premultiplied alpha', + category: 'Rendering', + mutuallyExclusive: ['_ALPHATEST_ON', '_ALPHABLEND_ON'] + } +]; + +export class StandardMaterialComponent extends BaseMaterialComponent { + constructor(config: StandardMaterialConfig = { materialType: MaterialType.STANDARD }) { + super({ + shaderName: 'Standard', + renderQueue: 2000, + blendMode: BlendMode.OPAQUE, + cullMode: CullMode.BACK, + shadowCasting: ShadowCasting.ON, + receiveShadows: true, + ...config + }); + } + + get albedo(): Vec4 { + return this.getProperty('_Color') || { x: 1, y: 1, z: 1, w: 1 }; + } + + set albedo(value: Vec4) { + this.setProperty('_Color', value); + } + + get albedoMap(): WebGLTexture | null { + return this.getProperty('_MainTex'); + } + + set albedoMap(value: WebGLTexture | null) { + this.setTexture('_MainTex', value); + } + + get metallic(): number { + return this.getProperty('_Metallic') || 0; + } + + set metallic(value: number) { + this.setProperty('_Metallic', Math.max(0, Math.min(1, value))); + } + + get metallicMap(): WebGLTexture | null { + return this.getProperty('_MetallicGlossMap'); + } + + set metallicMap(value: WebGLTexture | null) { + this.setTexture('_MetallicGlossMap', value); + if (value) { + this.enableKeyword('_METALLICGLOSSMAP'); + } else { + this.disableKeyword('_METALLICGLOSSMAP'); + } + } + + get smoothness(): number { + return this.getProperty('_Glossiness') || 0.5; + } + + set smoothness(value: number) { + this.setProperty('_Glossiness', Math.max(0, Math.min(1, value))); + } + + get normalMap(): WebGLTexture | null { + return this.getProperty('_BumpMap'); + } + + set normalMap(value: WebGLTexture | null) { + this.setTexture('_BumpMap', value); + if (value) { + this.enableKeyword('_NORMALMAP'); + } else { + this.disableKeyword('_NORMALMAP'); + } + } + + get normalScale(): number { + return this.getProperty('_BumpScale') || 1; + } + + set normalScale(value: number) { + this.setProperty('_BumpScale', Math.max(0, Math.min(2, value))); + } + + get heightMap(): WebGLTexture | null { + return this.getProperty('_ParallaxMap'); + } + + set heightMap(value: WebGLTexture | null) { + this.setTexture('_ParallaxMap', value); + if (value) { + this.enableKeyword('_PARALLAXMAP'); + } else { + this.disableKeyword('_PARALLAXMAP'); + } + } + + get heightScale(): number { + return this.getProperty('_Parallax') || 0.02; + } + + set heightScale(value: number) { + this.setProperty('_Parallax', Math.max(0.005, Math.min(0.08, value))); + } + + get occlusionMap(): WebGLTexture | null { + return this.getProperty('_OcclusionMap'); + } + + set occlusionMap(value: WebGLTexture | null) { + this.setTexture('_OcclusionMap', value); + if (value) { + this.enableKeyword('_OCCLUSIONMAP'); + } else { + this.disableKeyword('_OCCLUSIONMAP'); + } + } + + get occlusionStrength(): number { + return this.getProperty('_OcclusionStrength') || 1; + } + + set occlusionStrength(value: number) { + this.setProperty('_OcclusionStrength', Math.max(0, Math.min(1, value))); + } + + get emission(): Vec3 { + const color = this.getProperty('_EmissionColor'); + if (!color) return { x: 0, y: 0, z: 0 }; + return { x: color.x, y: color.y, z: color.z }; + } + + set emission(value: Vec3) { + const intensity = this.getProperty('_EmissionIntensity') || 1; + this.setProperty('_EmissionColor', { + x: value.x * intensity, + y: value.y * intensity, + z: value.z * intensity, + w: 1 + }); + + const hasEmission = value.x > 0 || value.y > 0 || value.z > 0; + if (hasEmission) { + this.enableKeyword('_EMISSION'); + } else { + this.disableKeyword('_EMISSION'); + } + } + + get emissionMap(): WebGLTexture | null { + return this.getProperty('_EmissionMap'); + } + + set emissionMap(value: WebGLTexture | null) { + this.setTexture('_EmissionMap', value); + if (value) { + this.enableKeyword('_EMISSION'); + } + } + + get emissionIntensity(): number { + return this.getProperty('_EmissionIntensity') || 1; + } + + set emissionIntensity(value: number) { + this.setProperty('_EmissionIntensity', Math.max(0, value)); + } + + get renderingMode(): 'Opaque' | 'Cutout' | 'Fade' | 'Transparent' { + const mode = this.getProperty('_Mode') || 0; + switch (mode) { + case 1: return 'Cutout'; + case 2: return 'Fade'; + case 3: return 'Transparent'; + default: return 'Opaque'; + } + } + + set renderingMode(value: 'Opaque' | 'Cutout' | 'Fade' | 'Transparent') { + let mode = 0; + switch (value) { + case 'Cutout': + mode = 1; + this.blendMode = BlendMode.ALPHA_TEST; + this.enableKeyword('_ALPHATEST_ON'); + this.disableKeyword('_ALPHABLEND_ON'); + this.disableKeyword('_ALPHAPREMULTIPLY_ON'); + this.renderQueue = 2450; + break; + case 'Fade': + mode = 2; + this.blendMode = BlendMode.ALPHA_BLEND; + this.disableKeyword('_ALPHATEST_ON'); + this.enableKeyword('_ALPHABLEND_ON'); + this.disableKeyword('_ALPHAPREMULTIPLY_ON'); + this.renderQueue = 3000; + break; + case 'Transparent': + mode = 3; + this.blendMode = BlendMode.PREMULTIPLIED; + this.disableKeyword('_ALPHATEST_ON'); + this.disableKeyword('_ALPHABLEND_ON'); + this.enableKeyword('_ALPHAPREMULTIPLY_ON'); + this.renderQueue = 3000; + break; + default: + mode = 0; + this.blendMode = BlendMode.OPAQUE; + this.disableKeyword('_ALPHATEST_ON'); + this.disableKeyword('_ALPHABLEND_ON'); + this.disableKeyword('_ALPHAPREMULTIPLY_ON'); + this.renderQueue = 2000; + break; + } + this.setProperty('_Mode', mode); + } + + get alphaCutoff(): number { + return this.getProperty('_Cutoff') || 0.5; + } + + set alphaCutoff(value: number) { + this.setProperty('_Cutoff', Math.max(0, Math.min(1, value))); + } + + get mainTextureScale(): Vec2 { + const st = this.getProperty('_MainTex_ST'); + return st ? { x: st.x, y: st.y } : { x: 1, y: 1 }; + } + + set mainTextureScale(value: Vec2) { + const st = this.getProperty('_MainTex_ST') || { x: 1, y: 1, z: 0, w: 0 }; + this.setProperty('_MainTex_ST', { x: value.x, y: value.y, z: st.z, w: st.w }); + } + + get mainTextureOffset(): Vec2 { + const st = this.getProperty('_MainTex_ST'); + return st ? { x: st.z, y: st.w } : { x: 0, y: 0 }; + } + + set mainTextureOffset(value: Vec2) { + const st = this.getProperty('_MainTex_ST') || { x: 1, y: 1, z: 0, w: 0 }; + this.setProperty('_MainTex_ST', { x: st.x, y: st.y, z: value.x, w: value.y }); + } + + protected _setupDefaultProperties(): void { + for (const prop of STANDARD_PROPERTIES) { + if (!this.hasProperty(prop.name)) { + this.setProperty(prop.name, prop.defaultValue); + } + } + } + + protected _setupDefaultKeywords(): void { + + if (this.albedoMap) this.enableKeyword('_MAINTEX'); + if (this.normalMap) this.enableKeyword('_NORMALMAP'); + if (this.metallicMap) this.enableKeyword('_METALLICGLOSSMAP'); + if (this.occlusionMap) this.enableKeyword('_OCCLUSIONMAP'); + if (this.heightMap) this.enableKeyword('_PARALLAXMAP'); + if (this.emissionMap) this.enableKeyword('_EMISSION'); + } + + protected _getAvailableProperties(): MaterialProperty[] { + return STANDARD_PROPERTIES; + } + + protected _getAvailableKeywords(): MaterialKeyword[] { + return STANDARD_KEYWORDS; + } + + protected _onPropertyChanged(name: string, value: any): void { + super._onPropertyChanged(name, value); + + switch (name) { + case '_EmissionColor': + const color = value as Vec4; + if (color && (color.x > 0 || color.y > 0 || color.z > 0)) { + this.enableKeyword('_EMISSION'); + } else { + this.disableKeyword('_EMISSION'); + } + break; + } + } + + public enableGlobalIllumination(): void { + this.setRenderTag('GlobalIllumination', 'RealtimeEmissive'); + } + + public disableGlobalIllumination(): void { + this.setRenderTag('GlobalIllumination', 'EmissiveIsBlack'); + } + + public setSmoothnessSource(source: 'metallic' | 'albedo'): void { + if (source === 'metallic') { + this.enableKeyword('_SMOOTHNESS_TEXTURE_METALLIC_CHANNEL_A'); + this.disableKeyword('_SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A'); + this.setProperty('_SmoothnessTextureChannel', 0); + } else { + this.disableKeyword('_SMOOTHNESS_TEXTURE_METALLIC_CHANNEL_A'); + this.enableKeyword('_SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A'); + this.setProperty('_SmoothnessTextureChannel', 1); + } + } +} \ No newline at end of file From b1ea9a9c5f26f9f2feeb4605ed679d08d83e3912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Thu, 23 Oct 2025 17:02:26 +0300 Subject: [PATCH 05/13] Add WebGL2 texture management module --- .../core/src/renderer/webgl2/texture/index.ts | 27 + .../src/renderer/webgl2/texture/interfaces.ts | 370 +++++++++++ .../src/renderer/webgl2/texture/manager.ts | 578 +++++++++++++++++ .../src/renderer/webgl2/texture/sampler.ts | 339 ++++++++++ .../src/renderer/webgl2/texture/texture.ts | 605 ++++++++++++++++++ .../core/src/renderer/webgl2/texture/utils.ts | 532 +++++++++++++++ 6 files changed, 2451 insertions(+) create mode 100644 packages/core/src/renderer/webgl2/texture/index.ts create mode 100644 packages/core/src/renderer/webgl2/texture/interfaces.ts create mode 100644 packages/core/src/renderer/webgl2/texture/manager.ts create mode 100644 packages/core/src/renderer/webgl2/texture/sampler.ts create mode 100644 packages/core/src/renderer/webgl2/texture/texture.ts create mode 100644 packages/core/src/renderer/webgl2/texture/utils.ts diff --git a/packages/core/src/renderer/webgl2/texture/index.ts b/packages/core/src/renderer/webgl2/texture/index.ts new file mode 100644 index 0000000..2efa07e --- /dev/null +++ b/packages/core/src/renderer/webgl2/texture/index.ts @@ -0,0 +1,27 @@ +export * from './interfaces'; + +export * from './utils'; + +export { WebGLTexture } from './texture'; + +export { WebGLTextureSampler, SamplerFactory, SamplerBuilder } from './sampler'; + +export { WebGLTextureManager } from './manager'; + +export { + TextureDimension, + TextureFormat, + FilterMode, + WrapMode, + TextureUsage, + ColorSpace, + TextureError, + TextureErrorCode +} from './interfaces'; + +export { + TextureFormatInfo, + TextureWebGLConstants, + TextureUtils, + TextureValidation +} from './utils'; diff --git a/packages/core/src/renderer/webgl2/texture/interfaces.ts b/packages/core/src/renderer/webgl2/texture/interfaces.ts new file mode 100644 index 0000000..44109ed --- /dev/null +++ b/packages/core/src/renderer/webgl2/texture/interfaces.ts @@ -0,0 +1,370 @@ +import { Vec2, Vec3, Vec4 } from '@axrone/numeric'; +import { ByteBuffer } from '@axrone/utility'; + +export const enum TextureDimension { + TEXTURE_1D = '1D', + TEXTURE_2D = '2D', + TEXTURE_3D = '3D', + TEXTURE_CUBE = 'CUBE', + TEXTURE_2D_ARRAY = '2D_ARRAY', + TEXTURE_CUBE_ARRAY = 'CUBE_ARRAY' +} + +export const enum TextureFormat { + + R8 = 'R8', + RG8 = 'RG8', + RGB8 = 'RGB8', + RGBA8 = 'RGBA8', + + R16F = 'R16F', + RG16F = 'RG16F', + RGB16F = 'RGB16F', + RGBA16F = 'RGBA16F', + + R32F = 'R32F', + RG32F = 'RG32F', + RGB32F = 'RGB32F', + RGBA32F = 'RGBA32F', + + R8I = 'R8I', + RG8I = 'RG8I', + RGB8I = 'RGB8I', + RGBA8I = 'RGBA8I', + + R16I = 'R16I', + RG16I = 'RG16I', + RGB16I = 'RGB16I', + RGBA16I = 'RGBA16I', + + R32I = 'R32I', + RG32I = 'RG32I', + RGB32I = 'RGB32I', + RGBA32I = 'RGBA32I', + + R8UI = 'R8UI', + RG8UI = 'RG8UI', + RGB8UI = 'RGB8UI', + RGBA8UI = 'RGBA8UI', + + R16UI = 'R16UI', + RG16UI = 'RG16UI', + RGB16UI = 'RGB16UI', + RGBA16UI = 'RGBA16UI', + + R32UI = 'R32UI', + RG32UI = 'RG32UI', + RGB32UI = 'RGB32UI', + RGBA32UI = 'RGBA32UI', + + DEPTH_COMPONENT16 = 'DEPTH_COMPONENT16', + DEPTH_COMPONENT24 = 'DEPTH_COMPONENT24', + DEPTH_COMPONENT32F = 'DEPTH_COMPONENT32F', + + DEPTH24_STENCIL8 = 'DEPTH24_STENCIL8', + DEPTH32F_STENCIL8 = 'DEPTH32F_STENCIL8', + + BC1_RGB = 'BC1_RGB', + BC1_RGBA = 'BC1_RGBA', + BC2_RGBA = 'BC2_RGBA', + BC3_RGBA = 'BC3_RGBA', + BC4_R = 'BC4_R', + BC5_RG = 'BC5_RG', + BC6H_RGB_UF16 = 'BC6H_RGB_UF16', + BC6H_RGB_SF16 = 'BC6H_RGB_SF16', + BC7_RGBA = 'BC7_RGBA', + + ASTC_4x4 = 'ASTC_4x4', + ASTC_5x4 = 'ASTC_5x4', + ASTC_5x5 = 'ASTC_5x5', + ASTC_6x5 = 'ASTC_6x5', + ASTC_6x6 = 'ASTC_6x6', + ASTC_8x5 = 'ASTC_8x5', + ASTC_8x6 = 'ASTC_8x6', + ASTC_8x8 = 'ASTC_8x8', + ASTC_10x5 = 'ASTC_10x5', + ASTC_10x6 = 'ASTC_10x6', + ASTC_10x8 = 'ASTC_10x8', + ASTC_10x10 = 'ASTC_10x10', + ASTC_12x10 = 'ASTC_12x10', + ASTC_12x12 = 'ASTC_12x12' +} + +export const enum FilterMode { + NEAREST = 'NEAREST', + LINEAR = 'LINEAR', + NEAREST_MIPMAP_NEAREST = 'NEAREST_MIPMAP_NEAREST', + LINEAR_MIPMAP_NEAREST = 'LINEAR_MIPMAP_NEAREST', + NEAREST_MIPMAP_LINEAR = 'NEAREST_MIPMAP_LINEAR', + LINEAR_MIPMAP_LINEAR = 'LINEAR_MIPMAP_LINEAR' +} + +export const enum WrapMode { + REPEAT = 'REPEAT', + CLAMP_TO_EDGE = 'CLAMP_TO_EDGE', + CLAMP_TO_BORDER = 'CLAMP_TO_BORDER', + MIRRORED_REPEAT = 'MIRRORED_REPEAT' +} + +export const enum TextureUsage { + STATIC = 'STATIC', + DYNAMIC = 'DYNAMIC', + STREAM = 'STREAM', + RENDER_TARGET = 'RENDER_TARGET', + DEPTH_BUFFER = 'DEPTH_BUFFER', + COMPUTE = 'COMPUTE' +} + +export const enum ColorSpace { + LINEAR = 'LINEAR', + SRGB = 'SRGB', + HDR10 = 'HDR10', + REC2020 = 'REC2020' +} + +export interface ITextureCreateOptions { + readonly width: number; + readonly height: number; + readonly depth?: number; + readonly format: TextureFormat; + readonly dimension: TextureDimension; + readonly mipLevels?: number; + readonly arrayLayers?: number; + readonly samples?: number; + readonly usage: TextureUsage; + readonly colorSpace?: ColorSpace; + readonly label?: string; +} + +export interface ITextureSamplerOptions { + readonly minFilter: FilterMode; + readonly magFilter: FilterMode; + readonly wrapS: WrapMode; + readonly wrapT: WrapMode; + readonly wrapR?: WrapMode; + readonly borderColor?: Vec4; + readonly maxAnisotropy?: number; + readonly compareMode?: 'NONE' | 'COMPARE_REF_TO_TEXTURE'; + readonly compareFunc?: 'NEVER' | 'LESS' | 'EQUAL' | 'LEQUAL' | 'GREATER' | 'NOTEQUAL' | 'GEQUAL' | 'ALWAYS'; + readonly minLod?: number; + readonly maxLod?: number; + readonly lodBias?: number; +} + +export type TextureDataSource = + | ArrayBufferView + | ImageData + | HTMLImageElement + | HTMLCanvasElement + | HTMLVideoElement + | ImageBitmap + | ByteBuffer + | null; + +export interface ITextureSubresource { + readonly mipLevel: number; + readonly arrayLayer?: number; + readonly x?: number; + readonly y?: number; + readonly z?: number; + readonly width?: number; + readonly height?: number; + readonly depth?: number; +} + +export interface IBindableTarget { + bind(unit?: number): void; + unbind(): void; +} + +export interface ITexture extends IBindableTarget { + readonly id: string; + readonly nativeHandle: WebGLTexture; + readonly dimension: TextureDimension; + readonly format: TextureFormat; + readonly width: number; + readonly height: number; + readonly depth: number; + readonly mipLevels: number; + readonly arrayLayers: number; + readonly samples: number; + readonly usage: TextureUsage; + readonly colorSpace: ColorSpace; + readonly label: string | null; + readonly isCompressed: boolean; + readonly bytesPerPixel: number; + readonly totalMemoryUsage: number; + readonly isDisposed: boolean; + + setData(data: TextureDataSource, subresource?: ITextureSubresource): void; + getData(subresource?: ITextureSubresource): Promise; + copyTo(destination: ITexture, sourceRegion?: ITextureSubresource, destRegion?: ITextureSubresource): void; + + generateMipmaps(): void; + hasMipmaps(): boolean; + + resize(width: number, height: number, depth?: number): void; + clone(): ITexture; + + bind(unit?: number): void; + unbind(): void; + + dispose(): void; +} + +export interface ITextureSampler extends IBindableTarget { + readonly id: string; + readonly nativeHandle: WebGLSampler; + readonly options: ITextureSamplerOptions; + readonly isDisposed: boolean; + + dispose(): void; +} + +export interface ITextureManager { + + createTexture(options: ITextureCreateOptions, data?: TextureDataSource): ITexture; + createTexture2D(width: number, height: number, format: TextureFormat, data?: TextureDataSource): ITexture; + createTexture3D(width: number, height: number, depth: number, format: TextureFormat, data?: TextureDataSource): ITexture; + createTextureCube(size: number, format: TextureFormat, data?: TextureDataSource[]): ITexture; + createTextureArray(width: number, height: number, layers: number, format: TextureFormat, data?: TextureDataSource[]): ITexture; + + createSampler(options: ITextureSamplerOptions): ITextureSampler; + getDefaultSampler(filterMode: FilterMode, wrapMode: WrapMode): ITextureSampler; + + loadFromFile(path: string, options?: Partial): Promise; + loadFromURL(url: string, options?: Partial): Promise; + loadCubeFromFiles(paths: [string, string, string, string, string, string]): Promise; + + getTexture(id: string): ITexture | null; + cacheTexture(id: string, texture: ITexture): void; + removeCachedTexture(id: string): boolean; + clearCache(): void; + + getWhiteTexture(): ITexture; + getBlackTexture(): ITexture; + getNormalTexture(): ITexture; + getCheckerboardTexture(): ITexture; + + getStats(): ITextureManagerStats; + optimizeMemory(): void; + dispose(): void; +} + +export interface ITextureManagerStats { + readonly totalTextures: number; + readonly totalMemoryUsage: number; + readonly texturesByFormat: Map; + readonly texturesByDimension: Map; + readonly texturesByUsage: Map; + readonly cacheHitRate: number; + readonly averageTextureSize: number; + readonly largestTexture: ITexture | null; +} + +export interface ITextureBuilder { + dimension(dim: TextureDimension): ITextureBuilder; + size(width: number, height: number, depth?: number): ITextureBuilder; + format(fmt: TextureFormat): ITextureBuilder; + mipLevels(levels: number): ITextureBuilder; + arrayLayers(layers: number): ITextureBuilder; + samples(count: number): ITextureBuilder; + usage(use: TextureUsage): ITextureBuilder; + colorSpace(space: ColorSpace): ITextureBuilder; + label(name: string): ITextureBuilder; + data(source: TextureDataSource): ITextureBuilder; + + filtering(min: FilterMode, mag: FilterMode): ITextureBuilder; + wrapping(s: WrapMode, t: WrapMode, r?: WrapMode): ITextureBuilder; + anisotropy(level: number): ITextureBuilder; + borderColor(color: Vec4): ITextureBuilder; + + build(): ITexture; +} + +export interface ITextureAtlasEntry { + readonly id: string; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly rotated: boolean; + readonly trimmed: boolean; + readonly sourceSize: Vec2; + readonly spriteSourceSize: { x: number; y: number; w: number; h: number }; + readonly uvBounds: { u0: number; v0: number; u1: number; v1: number }; +} + +export interface ITextureAtlas { + readonly texture: ITexture; + readonly entries: Map; + readonly totalEntries: number; + readonly efficiency: number; + + getEntry(id: string): ITextureAtlasEntry | null; + hasEntry(id: string): boolean; + getUVBounds(id: string): Vec4 | null; + + addTexture(id: string, source: TextureDataSource): ITextureAtlasEntry | null; + removeTexture(id: string): boolean; + optimize(): void; +} + +export interface ITextureCompressor { + readonly supportedFormats: readonly TextureFormat[]; + + compress(source: ITexture, targetFormat: TextureFormat, quality?: number): Promise; + decompress(data: ArrayBufferView, format: TextureFormat): Promise; + + estimateSize(width: number, height: number, format: TextureFormat): number; + isFormatSupported(format: TextureFormat): boolean; +} + +export interface ITextureStreamingOptions { + readonly maxConcurrentLoads: number; + readonly memoryBudget: number; + readonly priorityThreshold: number; + readonly enableCompression: boolean; + readonly compressionQuality: number; +} + +export interface ITextureStreaming { + readonly options: ITextureStreamingOptions; + readonly isEnabled: boolean; + readonly queuedRequests: number; + readonly memoryUsage: number; + + requestTexture(id: string, priority: number): Promise; + preloadTexture(id: string): Promise; + releaseTexture(id: string): void; + + setMemoryBudget(bytes: number): void; + optimize(): void; + + enable(): void; + disable(): void; +} + +export class TextureError extends Error { + constructor( + message: string, + public readonly code: TextureErrorCode, + public readonly textureId?: string, + public readonly cause?: Error + ) { + super(`[Texture] ${code}: ${message}`); + this.name = 'TextureError'; + } +} + +export const enum TextureErrorCode { + INVALID_DIMENSIONS = 'INVALID_DIMENSIONS', + UNSUPPORTED_FORMAT = 'UNSUPPORTED_FORMAT', + OUT_OF_MEMORY = 'OUT_OF_MEMORY', + INVALID_DATA = 'INVALID_DATA', + ALREADY_DISPOSED = 'ALREADY_DISPOSED', + CONTEXT_LOST = 'CONTEXT_LOST', + LOAD_FAILED = 'LOAD_FAILED', + COMPRESSION_FAILED = 'COMPRESSION_FAILED', + INVALID_OPERATION = 'INVALID_OPERATION' +} diff --git a/packages/core/src/renderer/webgl2/texture/manager.ts b/packages/core/src/renderer/webgl2/texture/manager.ts new file mode 100644 index 0000000..1c12ad9 --- /dev/null +++ b/packages/core/src/renderer/webgl2/texture/manager.ts @@ -0,0 +1,578 @@ +import { Vec4 } from '@axrone/numeric'; +import { + ITexture, + ITextureSampler, + ITextureManager, + ITextureManagerStats, + ITextureCreateOptions, + ITextureSamplerOptions, + ITextureBuilder, + TextureDimension, + TextureFormat, + TextureUsage, + FilterMode, + WrapMode, + TextureDataSource, + TextureError, + TextureErrorCode +} from './interfaces'; +import { WebGLTexture } from './texture'; +import { WebGLTextureSampler, SamplerFactory } from './sampler'; +import { TextureUtils, TextureValidation } from './utils'; + +export class WebGLTextureManager implements ITextureManager { + private readonly _gl: WebGL2RenderingContext; + private readonly _textureCache = new Map(); + private readonly _samplerCache = new Map(); + private readonly _defaultTextures = new Map(); + private readonly _loadPromises = new Map>(); + + private _stats = { + totalTextures: 0, + totalMemoryUsage: 0, + cacheHits: 0, + cacheMisses: 0, + texturesByFormat: new Map(), + texturesByDimension: new Map(), + texturesByUsage: new Map() + }; + + private _maxMemoryUsage: number = 512 * 1024 * 1024; + private _enableCache: boolean = true; + + constructor(gl: WebGL2RenderingContext) { + this._gl = gl; + this._initializeDefaultTextures(); + } + + public createTexture(options: ITextureCreateOptions, data?: TextureDataSource): ITexture { + TextureValidation.validateCreateOptions(options); + + const texture = new WebGLTexture(this._gl, options, data); + this._registerTexture(texture); + + return texture; + } + + public createTexture2D(width: number, height: number, format: TextureFormat, data?: TextureDataSource): ITexture { + const options: ITextureCreateOptions = { + width, + height, + format, + dimension: TextureDimension.TEXTURE_2D, + usage: TextureUsage.STATIC + }; + + return this.createTexture(options, data); + } + + public createTexture3D(width: number, height: number, depth: number, format: TextureFormat, data?: TextureDataSource): ITexture { + const options: ITextureCreateOptions = { + width, + height, + depth, + format, + dimension: TextureDimension.TEXTURE_3D, + usage: TextureUsage.STATIC + }; + + return this.createTexture(options, data); + } + + public createTextureCube(size: number, format: TextureFormat, data?: TextureDataSource[]): ITexture { + const options: ITextureCreateOptions = { + width: size, + height: size, + format, + dimension: TextureDimension.TEXTURE_CUBE, + usage: TextureUsage.STATIC + }; + + const texture = new WebGLTexture(this._gl, options); + + if (data && data.length === 6) { + for (let face = 0; face < 6; face++) { + if (data[face]) { + texture.setData(data[face], { mipLevel: 0, arrayLayer: face }); + } + } + } + + this._registerTexture(texture); + return texture; + } + + public createTextureArray(width: number, height: number, layers: number, format: TextureFormat, data?: TextureDataSource[]): ITexture { + const options: ITextureCreateOptions = { + width, + height, + arrayLayers: layers, + format, + dimension: TextureDimension.TEXTURE_2D_ARRAY, + usage: TextureUsage.STATIC + }; + + const texture = new WebGLTexture(this._gl, options); + + if (data) { + for (let layer = 0; layer < Math.min(data.length, layers); layer++) { + if (data[layer]) { + texture.setData(data[layer], { mipLevel: 0, arrayLayer: layer }); + } + } + } + + this._registerTexture(texture); + return texture; + } + + public createSampler(options: ITextureSamplerOptions): ITextureSampler { + TextureValidation.validateSamplerOptions(options); + return new WebGLTextureSampler(this._gl, options); + } + + public getDefaultSampler(filterMode: FilterMode, wrapMode: WrapMode): ITextureSampler { + const cacheKey = `${filterMode}_${wrapMode}`; + + if (!this._samplerCache.has(cacheKey)) { + const options: ITextureSamplerOptions = { + minFilter: filterMode, + magFilter: filterMode === FilterMode.LINEAR_MIPMAP_LINEAR ? FilterMode.LINEAR : filterMode, + wrapS: wrapMode, + wrapT: wrapMode + }; + + const sampler = new WebGLTextureSampler(this._gl, options); + this._samplerCache.set(cacheKey, sampler); + } + + return this._samplerCache.get(cacheKey)!; + } + + public async loadFromFile(path: string, options?: Partial): Promise { + + if (this._loadPromises.has(path)) { + return this._loadPromises.get(path)!; + } + + const loadPromise = this._loadTextureFromPath(path, options); + this._loadPromises.set(path, loadPromise); + + try { + const texture = await loadPromise; + return texture; + } finally { + this._loadPromises.delete(path); + } + } + + public async loadFromURL(url: string, options?: Partial): Promise { + return this.loadFromFile(url, options); + } + + public async loadCubeFromFiles(paths: [string, string, string, string, string, string]): Promise { + const loadPromises = paths.map(path => this._loadImageFromPath(path)); + const images = await Promise.all(loadPromises); + + const firstImage = images[0]; + const size = Math.max(firstImage.width, firstImage.height); + + return this.createTextureCube(size, TextureFormat.RGBA8, images); + } + + public getTexture(id: string): ITexture | null { + const texture = this._textureCache.get(id); + if (texture) { + this._stats.cacheHits++; + return texture; + } + + this._stats.cacheMisses++; + return null; + } + + public cacheTexture(id: string, texture: ITexture): void { + if (this._enableCache) { + this._textureCache.set(id, texture); + } + } + + public removeCachedTexture(id: string): boolean { + return this._textureCache.delete(id); + } + + public clearCache(): void { + + for (const texture of this._textureCache.values()) { + if (!texture.isDisposed) { + texture.dispose(); + } + } + + for (const sampler of this._samplerCache.values()) { + if (!sampler.isDisposed) { + sampler.dispose(); + } + } + + this._textureCache.clear(); + this._samplerCache.clear(); + this._loadPromises.clear(); + + this._stats.totalTextures = 0; + this._stats.totalMemoryUsage = 0; + this._stats.texturesByFormat.clear(); + this._stats.texturesByDimension.clear(); + this._stats.texturesByUsage.clear(); + } + + public getWhiteTexture(): ITexture { + return this._getOrCreateDefaultTexture('white', () => { + const data = new Uint8Array([255, 255, 255, 255]); + return this.createTexture2D(1, 1, TextureFormat.RGBA8, data); + }); + } + + public getBlackTexture(): ITexture { + return this._getOrCreateDefaultTexture('black', () => { + const data = new Uint8Array([0, 0, 0, 255]); + return this.createTexture2D(1, 1, TextureFormat.RGBA8, data); + }); + } + + public getNormalTexture(): ITexture { + return this._getOrCreateDefaultTexture('normal', () => { + const data = new Uint8Array([128, 128, 255, 255]); + return this.createTexture2D(1, 1, TextureFormat.RGBA8, data); + }); + } + + public getCheckerboardTexture(): ITexture { + return this._getOrCreateDefaultTexture('checkerboard', () => { + const size = 8; + const data = new Uint8Array(size * size * 4); + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const index = (y * size + x) * 4; + const isBlack = (x + y) % 2 === 0; + const color = isBlack ? 0 : 255; + + data[index] = color; + data[index + 1] = color; + data[index + 2] = color; + data[index + 3] = 255; + } + } + + return this.createTexture2D(size, size, TextureFormat.RGBA8, data); + }); + } + + public getStats(): ITextureManagerStats { + return { + totalTextures: this._stats.totalTextures, + totalMemoryUsage: this._stats.totalMemoryUsage, + texturesByFormat: new Map(this._stats.texturesByFormat), + texturesByDimension: new Map(this._stats.texturesByDimension), + texturesByUsage: new Map(this._stats.texturesByUsage), + cacheHitRate: this._stats.cacheHits / (this._stats.cacheHits + this._stats.cacheMisses), + averageTextureSize: this._stats.totalTextures > 0 ? this._stats.totalMemoryUsage / this._stats.totalTextures : 0, + largestTexture: this._findLargestTexture() + }; + } + + public optimizeMemory(): void { + if (this._stats.totalMemoryUsage <= this._maxMemoryUsage) { + return; + } + + const texturesToDispose: ITexture[] = []; + let memoryToFree = this._stats.totalMemoryUsage - this._maxMemoryUsage; + + for (const texture of this._textureCache.values()) { + if (memoryToFree <= 0) break; + + if (!this._isDefaultTexture(texture)) { + texturesToDispose.push(texture); + memoryToFree -= texture.totalMemoryUsage; + } + } + + for (const texture of texturesToDispose) { + this._unregisterTexture(texture); + texture.dispose(); + } + } + + public dispose(): void { + this.clearCache(); + + for (const texture of this._defaultTextures.values()) { + if (!texture.isDisposed) { + texture.dispose(); + } + } + this._defaultTextures.clear(); + } + + public builder(): ITextureBuilder { + return new TextureBuilder(this); + } + + private _initializeDefaultTextures(): void { + + } + + private _registerTexture(texture: ITexture): void { + this._stats.totalTextures++; + this._stats.totalMemoryUsage += texture.totalMemoryUsage; + + const formatCount = this._stats.texturesByFormat.get(texture.format) || 0; + this._stats.texturesByFormat.set(texture.format, formatCount + 1); + + const dimensionCount = this._stats.texturesByDimension.get(texture.dimension) || 0; + this._stats.texturesByDimension.set(texture.dimension, dimensionCount + 1); + + const usageCount = this._stats.texturesByUsage.get(texture.usage) || 0; + this._stats.texturesByUsage.set(texture.usage, usageCount + 1); + } + + private _unregisterTexture(texture: ITexture): void { + this._stats.totalTextures--; + this._stats.totalMemoryUsage -= texture.totalMemoryUsage; + + const formatCount = this._stats.texturesByFormat.get(texture.format) || 0; + if (formatCount > 1) { + this._stats.texturesByFormat.set(texture.format, formatCount - 1); + } else { + this._stats.texturesByFormat.delete(texture.format); + } + + const dimensionCount = this._stats.texturesByDimension.get(texture.dimension) || 0; + if (dimensionCount > 1) { + this._stats.texturesByDimension.set(texture.dimension, dimensionCount - 1); + } else { + this._stats.texturesByDimension.delete(texture.dimension); + } + + const usageCount = this._stats.texturesByUsage.get(texture.usage) || 0; + if (usageCount > 1) { + this._stats.texturesByUsage.set(texture.usage, usageCount - 1); + } else { + this._stats.texturesByUsage.delete(texture.usage); + } + + for (const [key, cachedTexture] of this._textureCache.entries()) { + if (cachedTexture === texture) { + this._textureCache.delete(key); + break; + } + } + } + + private _getOrCreateDefaultTexture(type: string, factory: () => ITexture): ITexture { + if (!this._defaultTextures.has(type)) { + this._defaultTextures.set(type, factory()); + } + return this._defaultTextures.get(type)!; + } + + private _isDefaultTexture(texture: ITexture): boolean { + return Array.from(this._defaultTextures.values()).includes(texture); + } + + private _findLargestTexture(): ITexture | null { + let largest: ITexture | null = null; + let maxMemory = 0; + + for (const texture of this._textureCache.values()) { + if (texture.totalMemoryUsage > maxMemory) { + maxMemory = texture.totalMemoryUsage; + largest = texture; + } + } + + return largest; + } + + private async _loadTextureFromPath(path: string, options?: Partial): Promise { + try { + const image = await this._loadImageFromPath(path); + + const createOptions: ITextureCreateOptions = { + width: image.width, + height: image.height, + format: TextureFormat.RGBA8, + dimension: TextureDimension.TEXTURE_2D, + usage: TextureUsage.STATIC, + mipLevels: 1, + ...options + }; + + const texture = this.createTexture(createOptions, image); + + if (createOptions.mipLevels! > 1) { + texture.generateMipmaps(); + } + + return texture; + } catch (error) { + throw new TextureError( + `Failed to load texture from path: ${path}`, + TextureErrorCode.LOAD_FAILED, + undefined, + error as Error + ); + } + } + + private async _loadImageFromPath(path: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + + image.onload = () => resolve(image); + image.onerror = () => reject(new Error(`Failed to load image: ${path}`)); + + if (path.startsWith('http')) { + image.crossOrigin = 'anonymous'; + } + + image.src = path; + }); + } +} + +class TextureBuilder implements ITextureBuilder { + private _options: { + width?: number; + height?: number; + depth?: number; + format?: TextureFormat; + dimension?: TextureDimension; + mipLevels?: number; + arrayLayers?: number; + samples?: number; + usage?: TextureUsage; + colorSpace?: any; + label?: string; + } = {}; + private _samplerOptions: { + minFilter?: FilterMode; + magFilter?: FilterMode; + wrapS?: WrapMode; + wrapT?: WrapMode; + wrapR?: WrapMode; + borderColor?: Vec4; + maxAnisotropy?: number; + } = {}; + private _data: TextureDataSource = null; + + constructor(private _manager: WebGLTextureManager) {} + + public dimension(dim: TextureDimension): ITextureBuilder { + this._options.dimension = dim; + return this; + } + + public size(width: number, height: number, depth?: number): ITextureBuilder { + this._options.width = width; + this._options.height = height; + if (depth !== undefined) { + this._options.depth = depth; + } + return this; + } + + public format(fmt: TextureFormat): ITextureBuilder { + this._options.format = fmt; + return this; + } + + public mipLevels(levels: number): ITextureBuilder { + this._options.mipLevels = levels; + return this; + } + + public arrayLayers(layers: number): ITextureBuilder { + this._options.arrayLayers = layers; + return this; + } + + public samples(count: number): ITextureBuilder { + this._options.samples = count; + return this; + } + + public usage(use: TextureUsage): ITextureBuilder { + this._options.usage = use; + return this; + } + + public colorSpace(space: any): ITextureBuilder { + this._options.colorSpace = space; + return this; + } + + public label(name: string): ITextureBuilder { + this._options.label = name; + return this; + } + + public data(source: TextureDataSource): ITextureBuilder { + this._data = source; + return this; + } + + public filtering(min: FilterMode, mag: FilterMode): ITextureBuilder { + this._samplerOptions.minFilter = min; + this._samplerOptions.magFilter = mag; + return this; + } + + public wrapping(s: WrapMode, t: WrapMode, r?: WrapMode): ITextureBuilder { + this._samplerOptions.wrapS = s; + this._samplerOptions.wrapT = t; + if (r !== undefined) { + this._samplerOptions.wrapR = r; + } + return this; + } + + public anisotropy(level: number): ITextureBuilder { + this._samplerOptions.maxAnisotropy = level; + return this; + } + + public borderColor(color: Vec4): ITextureBuilder { + this._samplerOptions.borderColor = color; + return this; + } + + public build(): ITexture { + + if (!this._options.width || !this._options.height) { + throw new TextureError( + 'Width and height are required', + TextureErrorCode.INVALID_DIMENSIONS + ); + } + + if (!this._options.format) { + throw new TextureError( + 'Texture format is required', + TextureErrorCode.UNSUPPORTED_FORMAT + ); + } + + if (!this._options.dimension) { + this._options.dimension = TextureDimension.TEXTURE_2D; + } + + if (!this._options.usage) { + this._options.usage = TextureUsage.STATIC; + } + + return this._manager.createTexture(this._options as ITextureCreateOptions, this._data); + } +} diff --git a/packages/core/src/renderer/webgl2/texture/sampler.ts b/packages/core/src/renderer/webgl2/texture/sampler.ts new file mode 100644 index 0000000..0e8bf7d --- /dev/null +++ b/packages/core/src/renderer/webgl2/texture/sampler.ts @@ -0,0 +1,339 @@ +import { Vec4 } from '@axrone/numeric'; +import { + ITextureSampler, + ITextureSamplerOptions, + FilterMode, + WrapMode, + TextureError, + TextureErrorCode +} from './interfaces'; +import { + TextureWebGLConstants, + TextureValidation +} from './utils'; + +export class WebGLTextureSampler implements ITextureSampler { + public readonly id: string; + public readonly nativeHandle: WebGLSampler; + public readonly options: ITextureSamplerOptions; + + private _isDisposed = false; + private _currentUnit = -1; + + private readonly _gl: WebGL2RenderingContext; + + constructor(gl: WebGL2RenderingContext, options: ITextureSamplerOptions) { + this._gl = gl; + + TextureValidation.validateSamplerOptions(options); + + this.id = this._generateSamplerId(); + this.options = { ...options }; + + const handle = this._gl.createSampler(); + if (!handle) { + throw new TextureError( + 'Failed to create WebGL sampler', + TextureErrorCode.CONTEXT_LOST, + this.id + ); + } + this.nativeHandle = handle; + + this._configureSampler(); + } + + public get isDisposed(): boolean { + return this._isDisposed; + } + + public bind(unit?: number): void { + this._validateNotDisposed(); + + if (unit === undefined) { + throw new TextureError( + 'Texture unit is required for sampler binding', + TextureErrorCode.INVALID_OPERATION, + this.id + ); + } + + if (unit < 0) { + throw new TextureError( + 'Texture unit must be non-negative', + TextureErrorCode.INVALID_OPERATION, + this.id + ); + } + + this._gl.bindSampler(unit, this.nativeHandle); + this._currentUnit = unit; + } + + public unbind(): void { + if (this._currentUnit >= 0) { + this._gl.bindSampler(this._currentUnit, null); + this._currentUnit = -1; + } + } + + public dispose(): void { + if (this._isDisposed) { + return; + } + + this._gl.deleteSampler(this.nativeHandle); + this._isDisposed = true; + this._currentUnit = -1; + } + + private _validateNotDisposed(): void { + if (this._isDisposed) { + throw new TextureError( + 'Sampler has been disposed', + TextureErrorCode.ALREADY_DISPOSED, + this.id + ); + } + } + + private _generateSamplerId(): string { + return `sampler_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + private _configureSampler(): void { + const gl = this._gl; + const sampler = this.nativeHandle; + + gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, + TextureWebGLConstants.getFilterConstant(this.options.minFilter)); + gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, + TextureWebGLConstants.getFilterConstant(this.options.magFilter)); + + gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, + TextureWebGLConstants.getWrapConstant(this.options.wrapS)); + gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, + TextureWebGLConstants.getWrapConstant(this.options.wrapT)); + + if (this.options.wrapR !== undefined) { + gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_R, + TextureWebGLConstants.getWrapConstant(this.options.wrapR)); + } + + if (this.options.borderColor) { + const color = this.options.borderColor; + + } + + if (this.options.maxAnisotropy !== undefined && this.options.maxAnisotropy > 1) { + const ext = gl.getExtension('EXT_texture_filter_anisotropic'); + if (ext) { + const maxAnisotropy = Math.min( + this.options.maxAnisotropy, + gl.getParameter(ext.MAX_TEXTURE_MAX_ANISOTROPY_EXT) + ); + gl.samplerParameterf(sampler, ext.TEXTURE_MAX_ANISOTROPY_EXT, maxAnisotropy); + } + } + + if (this.options.compareMode === 'COMPARE_REF_TO_TEXTURE') { + gl.samplerParameteri(sampler, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE); + + if (this.options.compareFunc) { + const compareFuncs: Record = { + 'NEVER': gl.NEVER, + 'LESS': gl.LESS, + 'EQUAL': gl.EQUAL, + 'LEQUAL': gl.LEQUAL, + 'GREATER': gl.GREATER, + 'NOTEQUAL': gl.NOTEQUAL, + 'GEQUAL': gl.GEQUAL, + 'ALWAYS': gl.ALWAYS + }; + + gl.samplerParameteri(sampler, gl.TEXTURE_COMPARE_FUNC, + compareFuncs[this.options.compareFunc]); + } + } else { + gl.samplerParameteri(sampler, gl.TEXTURE_COMPARE_MODE, gl.NONE); + } + + if (this.options.minLod !== undefined) { + gl.samplerParameterf(sampler, gl.TEXTURE_MIN_LOD, this.options.minLod); + } + + if (this.options.maxLod !== undefined) { + gl.samplerParameterf(sampler, gl.TEXTURE_MAX_LOD, this.options.maxLod); + } + + if (this.options.lodBias !== undefined) { + + } + } +} + +export class SamplerFactory { + private static readonly _commonSamplers = new Map(); + + static { + + this._commonSamplers.set('linear_repeat', { + minFilter: FilterMode.LINEAR, + magFilter: FilterMode.LINEAR, + wrapS: WrapMode.REPEAT, + wrapT: WrapMode.REPEAT + }); + + this._commonSamplers.set('linear_clamp', { + minFilter: FilterMode.LINEAR, + magFilter: FilterMode.LINEAR, + wrapS: WrapMode.CLAMP_TO_EDGE, + wrapT: WrapMode.CLAMP_TO_EDGE + }); + + this._commonSamplers.set('nearest_repeat', { + minFilter: FilterMode.NEAREST, + magFilter: FilterMode.NEAREST, + wrapS: WrapMode.REPEAT, + wrapT: WrapMode.REPEAT + }); + + this._commonSamplers.set('nearest_clamp', { + minFilter: FilterMode.NEAREST, + magFilter: FilterMode.NEAREST, + wrapS: WrapMode.CLAMP_TO_EDGE, + wrapT: WrapMode.CLAMP_TO_EDGE + }); + + this._commonSamplers.set('trilinear', { + minFilter: FilterMode.LINEAR_MIPMAP_LINEAR, + magFilter: FilterMode.LINEAR, + wrapS: WrapMode.REPEAT, + wrapT: WrapMode.REPEAT, + maxAnisotropy: 16 + }); + + this._commonSamplers.set('shadow', { + minFilter: FilterMode.LINEAR, + magFilter: FilterMode.LINEAR, + wrapS: WrapMode.CLAMP_TO_EDGE, + wrapT: WrapMode.CLAMP_TO_EDGE, + compareMode: 'COMPARE_REF_TO_TEXTURE', + compareFunc: 'LEQUAL' + }); + } + + public static createCommonSampler( + gl: WebGL2RenderingContext, + type: 'linear_repeat' | 'linear_clamp' | 'nearest_repeat' | 'nearest_clamp' | 'trilinear' | 'shadow' + ): WebGLTextureSampler { + const options = this._commonSamplers.get(type); + if (!options) { + throw new TextureError( + `Unknown common sampler type: ${type}`, + TextureErrorCode.INVALID_OPERATION + ); + } + + return new WebGLTextureSampler(gl, options); + } + + public static builder(): SamplerBuilder { + return new SamplerBuilder(); + } +} + +export class SamplerBuilder { + private _options: { + minFilter?: FilterMode; + magFilter?: FilterMode; + wrapS?: WrapMode; + wrapT?: WrapMode; + wrapR?: WrapMode; + borderColor?: Vec4; + maxAnisotropy?: number; + compareMode?: 'NONE' | 'COMPARE_REF_TO_TEXTURE'; + compareFunc?: 'NEVER' | 'LESS' | 'EQUAL' | 'LEQUAL' | 'GREATER' | 'NOTEQUAL' | 'GEQUAL' | 'ALWAYS'; + minLod?: number; + maxLod?: number; + lodBias?: number; + } = {}; + + public minFilter(filter: FilterMode): SamplerBuilder { + this._options.minFilter = filter; + return this; + } + + public magFilter(filter: FilterMode): SamplerBuilder { + this._options.magFilter = filter; + return this; + } + + public wrapS(wrap: WrapMode): SamplerBuilder { + this._options.wrapS = wrap; + return this; + } + + public wrapT(wrap: WrapMode): SamplerBuilder { + this._options.wrapT = wrap; + return this; + } + + public wrapR(wrap: WrapMode): SamplerBuilder { + this._options.wrapR = wrap; + return this; + } + + public wrapAll(wrap: WrapMode): SamplerBuilder { + this._options.wrapS = wrap; + this._options.wrapT = wrap; + this._options.wrapR = wrap; + return this; + } + + public borderColor(color: Vec4): SamplerBuilder { + this._options.borderColor = color; + return this; + } + + public anisotropy(level: number): SamplerBuilder { + this._options.maxAnisotropy = level; + return this; + } + + public shadowComparison(func: 'NEVER' | 'LESS' | 'EQUAL' | 'LEQUAL' | 'GREATER' | 'NOTEQUAL' | 'GEQUAL' | 'ALWAYS'): SamplerBuilder { + this._options.compareMode = 'COMPARE_REF_TO_TEXTURE'; + this._options.compareFunc = func; + return this; + } + + public lodRange(min: number, max: number): SamplerBuilder { + this._options.minLod = min; + this._options.maxLod = max; + return this; + } + + public lodBias(bias: number): SamplerBuilder { + this._options.lodBias = bias; + return this; + } + + public build(gl: WebGL2RenderingContext): WebGLTextureSampler { + + if (!this._options.minFilter || !this._options.magFilter) { + throw new TextureError( + 'Min and mag filters are required', + TextureErrorCode.INVALID_OPERATION + ); + } + + if (!this._options.wrapS || !this._options.wrapT) { + throw new TextureError( + 'Wrap modes for S and T are required', + TextureErrorCode.INVALID_OPERATION + ); + } + + return new WebGLTextureSampler(gl, this._options as ITextureSamplerOptions); + } +} diff --git a/packages/core/src/renderer/webgl2/texture/texture.ts b/packages/core/src/renderer/webgl2/texture/texture.ts new file mode 100644 index 0000000..7f3febf --- /dev/null +++ b/packages/core/src/renderer/webgl2/texture/texture.ts @@ -0,0 +1,605 @@ +import { Vec2, Vec3, Vec4 } from '@axrone/numeric'; +import { ByteBuffer } from '@axrone/utility'; +import { + ITexture, + ITextureCreateOptions, + ITextureSubresource, + TextureDimension, + TextureFormat, + TextureUsage, + ColorSpace, + TextureDataSource, + TextureError, + TextureErrorCode +} from './interfaces'; +import { + TextureFormatInfo, + TextureUtils, + TextureWebGLConstants, + TextureValidation +} from './utils'; + +export class WebGLTexture implements ITexture { + + public readonly id: string; + public readonly nativeHandle: globalThis.WebGLTexture; + public readonly dimension: TextureDimension; + public readonly format: TextureFormat; + public readonly width: number; + public readonly height: number; + public readonly depth: number; + public readonly mipLevels: number; + public readonly arrayLayers: number; + public readonly samples: number; + public readonly usage: TextureUsage; + public readonly colorSpace: ColorSpace; + public readonly label: string | null; + + public readonly isCompressed: boolean; + public readonly bytesPerPixel: number; + public readonly totalMemoryUsage: number; + + private _isDisposed = false; + private _currentUnit = -1; + private _generation = 0; + + private readonly _gl: WebGL2RenderingContext; + private readonly _target: number; + + constructor(gl: WebGL2RenderingContext, options: ITextureCreateOptions, data?: TextureDataSource) { + this._gl = gl; + + TextureValidation.validateCreateOptions(options); + + this.id = TextureUtils.generateTextureId(); + this.dimension = options.dimension; + this.format = options.format; + this.width = options.width; + this.height = options.height; + this.depth = options.depth || 1; + this.mipLevels = options.mipLevels || 1; + this.arrayLayers = options.arrayLayers || 1; + this.samples = options.samples || 1; + this.usage = options.usage; + this.colorSpace = options.colorSpace || ColorSpace.LINEAR; + this.label = options.label || null; + + const formatInfo = TextureFormatInfo.getFormatInfo(this.format); + this.isCompressed = formatInfo.compressed; + this.bytesPerPixel = formatInfo.bytesPerPixel; + this.totalMemoryUsage = TextureUtils.calculateMemoryUsage( + this.width, + this.height, + this.depth, + this.format, + this.mipLevels + ); + + this._target = TextureWebGLConstants.getDimensionConstant(this.dimension); + + const handle = this._gl.createTexture(); + if (!handle) { + throw new TextureError( + 'Failed to create WebGL texture', + TextureErrorCode.CONTEXT_LOST, + this.id + ); + } + this.nativeHandle = handle as globalThis.WebGLTexture; + + this._initializeStorage(); + + if (data !== null && data !== undefined) { + this.setData(data); + } + + if (this.label && this._gl.getExtension('WEBGL_debug_renderer_info')) { + this._gl.bindTexture(this._target, this.nativeHandle); + + this._gl.bindTexture(this._target, null); + } + } + + public get isDisposed(): boolean { + return this._isDisposed; + } + + public setData(data: TextureDataSource, subresource?: ITextureSubresource): void { + this._validateNotDisposed(); + + const mipLevel = subresource?.mipLevel || 0; + const arrayLayer = subresource?.arrayLayer || 0; + + const mipDims = TextureUtils.getMipDimensions(this.width, this.height, this.depth, mipLevel); + const x = subresource?.x || 0; + const y = subresource?.y || 0; + const z = subresource?.z || 0; + const width = subresource?.width || mipDims.width; + const height = subresource?.height || mipDims.height; + const depth = subresource?.depth || mipDims.depth; + + this.bind(); + + try { + this._uploadData(data, mipLevel, arrayLayer, x, y, z, width, height, depth); + this._generation++; + } finally { + this.unbind(); + } + } + + public async getData(subresource?: ITextureSubresource): Promise { + this._validateNotDisposed(); + + throw new TextureError( + 'Direct texture data reading not implemented - use framebuffer readback', + TextureErrorCode.INVALID_OPERATION, + this.id + ); + } + + public copyTo(destination: ITexture, sourceRegion?: ITextureSubresource, destRegion?: ITextureSubresource): void { + this._validateNotDisposed(); + + if (destination.isDisposed) { + throw new TextureError( + 'Cannot copy to disposed texture', + TextureErrorCode.ALREADY_DISPOSED, + destination.id + ); + } + + throw new TextureError( + 'Texture copying not yet implemented', + TextureErrorCode.INVALID_OPERATION, + this.id + ); + } + + public generateMipmaps(): void { + this._validateNotDisposed(); + + if (this.mipLevels <= 1) { + return; + } + + if (this.isCompressed) { + throw new TextureError( + 'Cannot generate mipmaps for compressed textures', + TextureErrorCode.INVALID_OPERATION, + this.id + ); + } + + this.bind(); + this._gl.generateMipmap(this._target); + this.unbind(); + + this._generation++; + } + + public hasMipmaps(): boolean { + return this.mipLevels > 1; + } + + public resize(width: number, height: number, depth?: number): void { + this._validateNotDisposed(); + + const newDepth = depth !== undefined ? depth : this.depth; + + TextureUtils.validateDimensions(width, height, newDepth, this.dimension); + + const newOptions: ITextureCreateOptions = { + width, + height, + depth: newDepth, + format: this.format, + dimension: this.dimension, + mipLevels: this.mipLevels, + arrayLayers: this.arrayLayers, + samples: this.samples, + usage: this.usage, + colorSpace: this.colorSpace, + label: this.label || undefined + }; + + this._initializeStorageWithOptions(newOptions); + this._generation++; + } + + public clone(): ITexture { + this._validateNotDisposed(); + + const cloneOptions: ITextureCreateOptions = { + width: this.width, + height: this.height, + depth: this.depth, + format: this.format, + dimension: this.dimension, + mipLevels: this.mipLevels, + arrayLayers: this.arrayLayers, + samples: this.samples, + usage: this.usage, + colorSpace: this.colorSpace, + label: this.label ? `${this.label}_clone` : undefined + }; + + const clone = new WebGLTexture(this._gl, cloneOptions); + + return clone; + } + + public bind(unit?: number): void { + this._validateNotDisposed(); + + if (unit !== undefined) { + this._gl.activeTexture(this._gl.TEXTURE0 + unit); + this._currentUnit = unit; + } + + this._gl.bindTexture(this._target, this.nativeHandle); + } + + public unbind(): void { + if (this._currentUnit >= 0) { + this._gl.activeTexture(this._gl.TEXTURE0 + this._currentUnit); + } + this._gl.bindTexture(this._target, null); + this._currentUnit = -1; + } + + public dispose(): void { + if (this._isDisposed) { + return; + } + + this._gl.deleteTexture(this.nativeHandle); + this._isDisposed = true; + this._currentUnit = -1; + } + + private _validateNotDisposed(): void { + if (this._isDisposed) { + throw new TextureError( + 'Texture has been disposed', + TextureErrorCode.ALREADY_DISPOSED, + this.id + ); + } + } + + private _initializeStorage(): void { + const options: ITextureCreateOptions = { + width: this.width, + height: this.height, + depth: this.depth, + format: this.format, + dimension: this.dimension, + mipLevels: this.mipLevels, + arrayLayers: this.arrayLayers, + samples: this.samples, + usage: this.usage, + colorSpace: this.colorSpace + }; + + this._initializeStorageWithOptions(options); + } + + private _initializeStorageWithOptions(options: ITextureCreateOptions): void { + this.bind(); + + const formatInfo = TextureFormatInfo.getFormatInfo(options.format); + + try { + switch (options.dimension) { + case TextureDimension.TEXTURE_2D: + this._initializeTexture2D(options, formatInfo); + break; + + case TextureDimension.TEXTURE_3D: + this._initializeTexture3D(options, formatInfo); + break; + + case TextureDimension.TEXTURE_CUBE: + this._initializeTextureCube(options, formatInfo); + break; + + case TextureDimension.TEXTURE_2D_ARRAY: + this._initializeTexture2DArray(options, formatInfo); + break; + + default: + throw new TextureError( + `Unsupported texture dimension: ${options.dimension}`, + TextureErrorCode.UNSUPPORTED_FORMAT, + this.id + ); + } + } finally { + this.unbind(); + } + } + + private _initializeTexture2D(options: ITextureCreateOptions, formatInfo: any): void { + for (let mip = 0; mip < options.mipLevels!; mip++) { + const mipDims = TextureUtils.getMipDimensions(options.width, options.height, 1, mip); + + this._gl.texImage2D( + this._target, + mip, + formatInfo.internalFormat, + mipDims.width, + mipDims.height, + 0, + formatInfo.format, + formatInfo.type, + null + ); + } + } + + private _initializeTexture3D(options: ITextureCreateOptions, formatInfo: any): void { + for (let mip = 0; mip < options.mipLevels!; mip++) { + const mipDims = TextureUtils.getMipDimensions(options.width, options.height, options.depth!, mip); + + this._gl.texImage3D( + this._target, + mip, + formatInfo.internalFormat, + mipDims.width, + mipDims.height, + mipDims.depth, + 0, + formatInfo.format, + formatInfo.type, + null + ); + } + } + + private _initializeTextureCube(options: ITextureCreateOptions, formatInfo: any): void { + const faces = [ + this._gl.TEXTURE_CUBE_MAP_POSITIVE_X, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_X, + this._gl.TEXTURE_CUBE_MAP_POSITIVE_Y, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, + this._gl.TEXTURE_CUBE_MAP_POSITIVE_Z, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_Z + ]; + + for (let face = 0; face < 6; face++) { + for (let mip = 0; mip < options.mipLevels!; mip++) { + const mipDims = TextureUtils.getMipDimensions(options.width, options.height, 1, mip); + + this._gl.texImage2D( + faces[face], + mip, + formatInfo.internalFormat, + mipDims.width, + mipDims.height, + 0, + formatInfo.format, + formatInfo.type, + null + ); + } + } + } + + private _initializeTexture2DArray(options: ITextureCreateOptions, formatInfo: any): void { + for (let mip = 0; mip < options.mipLevels!; mip++) { + const mipDims = TextureUtils.getMipDimensions(options.width, options.height, 1, mip); + + this._gl.texImage3D( + this._target, + mip, + formatInfo.internalFormat, + mipDims.width, + mipDims.height, + options.arrayLayers!, + 0, + formatInfo.format, + formatInfo.type, + null + ); + } + } + + private _uploadData( + data: TextureDataSource, + mipLevel: number, + arrayLayer: number, + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number + ): void { + const formatInfo = TextureFormatInfo.getFormatInfo(this.format); + + if (data === null) { + return; + } + + if (data instanceof ByteBuffer) { + this._uploadBufferData(data, formatInfo, mipLevel, arrayLayer, x, y, z, width, height, depth); + } else if (data instanceof HTMLImageElement || + data instanceof HTMLCanvasElement || + data instanceof HTMLVideoElement || + data instanceof ImageBitmap) { + this._uploadImageData(data, formatInfo, mipLevel, arrayLayer, x, y); + } else if (data instanceof ImageData) { + this._uploadImageData(data, formatInfo, mipLevel, arrayLayer, x, y); + } else if (ArrayBuffer.isView(data)) { + this._uploadTypedArrayData(data, formatInfo, mipLevel, arrayLayer, x, y, z, width, height, depth); + } else { + throw new TextureError( + 'Unsupported data source type', + TextureErrorCode.INVALID_DATA, + this.id + ); + } + } + + private _uploadBufferData( + buffer: ByteBuffer, + formatInfo: any, + mipLevel: number, + arrayLayer: number, + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number + ): void { + + const typedView = buffer.asTypedView('uint8'); + const values = typedView.getValues(0, typedView.capacity); + const data = new Uint8Array(values); + this._uploadTypedArrayData(data, formatInfo, mipLevel, arrayLayer, x, y, z, width, height, depth); + } + + private _uploadImageData( + image: TexImageSource, + formatInfo: any, + mipLevel: number, + arrayLayer: number, + x: number, + y: number + ): void { + switch (this.dimension) { + case TextureDimension.TEXTURE_2D: + this._gl.texSubImage2D( + this._target, + mipLevel, + x, + y, + formatInfo.format, + formatInfo.type, + image + ); + break; + + case TextureDimension.TEXTURE_CUBE: + const faces = [ + this._gl.TEXTURE_CUBE_MAP_POSITIVE_X, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_X, + this._gl.TEXTURE_CUBE_MAP_POSITIVE_Y, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, + this._gl.TEXTURE_CUBE_MAP_POSITIVE_Z, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_Z + ]; + + this._gl.texSubImage2D( + faces[arrayLayer], + mipLevel, + x, + y, + formatInfo.format, + formatInfo.type, + image + ); + break; + + default: + throw new TextureError( + `Image upload not supported for dimension: ${this.dimension}`, + TextureErrorCode.INVALID_OPERATION, + this.id + ); + } + } + + private _uploadTypedArrayData( + data: ArrayBufferView, + formatInfo: any, + mipLevel: number, + arrayLayer: number, + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number + ): void { + switch (this.dimension) { + case TextureDimension.TEXTURE_2D: + this._gl.texSubImage2D( + this._target, + mipLevel, + x, + y, + width, + height, + formatInfo.format, + formatInfo.type, + data + ); + break; + + case TextureDimension.TEXTURE_3D: + this._gl.texSubImage3D( + this._target, + mipLevel, + x, + y, + z, + width, + height, + depth, + formatInfo.format, + formatInfo.type, + data + ); + break; + + case TextureDimension.TEXTURE_2D_ARRAY: + this._gl.texSubImage3D( + this._target, + mipLevel, + x, + y, + arrayLayer, + width, + height, + 1, + formatInfo.format, + formatInfo.type, + data + ); + break; + + case TextureDimension.TEXTURE_CUBE: + const faces = [ + this._gl.TEXTURE_CUBE_MAP_POSITIVE_X, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_X, + this._gl.TEXTURE_CUBE_MAP_POSITIVE_Y, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, + this._gl.TEXTURE_CUBE_MAP_POSITIVE_Z, + this._gl.TEXTURE_CUBE_MAP_NEGATIVE_Z + ]; + + this._gl.texSubImage2D( + faces[arrayLayer], + mipLevel, + x, + y, + width, + height, + formatInfo.format, + formatInfo.type, + data + ); + break; + + default: + throw new TextureError( + `Data upload not supported for dimension: ${this.dimension}`, + TextureErrorCode.INVALID_OPERATION, + this.id + ); + } + } +} diff --git a/packages/core/src/renderer/webgl2/texture/utils.ts b/packages/core/src/renderer/webgl2/texture/utils.ts new file mode 100644 index 0000000..e654470 --- /dev/null +++ b/packages/core/src/renderer/webgl2/texture/utils.ts @@ -0,0 +1,532 @@ +import { Vec2, Vec3, Vec4 } from '@axrone/numeric'; +import { + TextureDimension, + TextureFormat, + FilterMode, + WrapMode, + TextureUsage, + ColorSpace, + ITextureCreateOptions, + ITextureSamplerOptions, + TextureError, + TextureErrorCode +} from './interfaces'; + +interface FormatInfo { + readonly internalFormat: number; + readonly format: number; + readonly type: number; + readonly bytesPerPixel: number; + readonly channels: number; + readonly compressed: boolean; + readonly blockSize?: number; + readonly floatingPoint: boolean; + readonly integer: boolean; + readonly depth: boolean; + readonly stencil: boolean; + readonly srgb: boolean; +} + +export class TextureFormatInfo { + private static readonly formatDatabase = new Map([ + + [TextureFormat.R8, { + internalFormat: WebGL2RenderingContext.R8, + format: WebGL2RenderingContext.RED, + type: WebGL2RenderingContext.UNSIGNED_BYTE, + bytesPerPixel: 1, channels: 1, compressed: false, floatingPoint: false, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RG8, { + internalFormat: WebGL2RenderingContext.RG8, + format: WebGL2RenderingContext.RG, + type: WebGL2RenderingContext.UNSIGNED_BYTE, + bytesPerPixel: 2, channels: 2, compressed: false, floatingPoint: false, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RGB8, { + internalFormat: WebGL2RenderingContext.RGB8, + format: WebGL2RenderingContext.RGB, + type: WebGL2RenderingContext.UNSIGNED_BYTE, + bytesPerPixel: 3, channels: 3, compressed: false, floatingPoint: false, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RGBA8, { + internalFormat: WebGL2RenderingContext.RGBA8, + format: WebGL2RenderingContext.RGBA, + type: WebGL2RenderingContext.UNSIGNED_BYTE, + bytesPerPixel: 4, channels: 4, compressed: false, floatingPoint: false, integer: false, depth: false, stencil: false, srgb: false + }], + + [TextureFormat.R16F, { + internalFormat: WebGL2RenderingContext.R16F, + format: WebGL2RenderingContext.RED, + type: WebGL2RenderingContext.HALF_FLOAT, + bytesPerPixel: 2, channels: 1, compressed: false, floatingPoint: true, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RG16F, { + internalFormat: WebGL2RenderingContext.RG16F, + format: WebGL2RenderingContext.RG, + type: WebGL2RenderingContext.HALF_FLOAT, + bytesPerPixel: 4, channels: 2, compressed: false, floatingPoint: true, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RGB16F, { + internalFormat: WebGL2RenderingContext.RGB16F, + format: WebGL2RenderingContext.RGB, + type: WebGL2RenderingContext.HALF_FLOAT, + bytesPerPixel: 6, channels: 3, compressed: false, floatingPoint: true, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RGBA16F, { + internalFormat: WebGL2RenderingContext.RGBA16F, + format: WebGL2RenderingContext.RGBA, + type: WebGL2RenderingContext.HALF_FLOAT, + bytesPerPixel: 8, channels: 4, compressed: false, floatingPoint: true, integer: false, depth: false, stencil: false, srgb: false + }], + + [TextureFormat.R32F, { + internalFormat: WebGL2RenderingContext.R32F, + format: WebGL2RenderingContext.RED, + type: WebGL2RenderingContext.FLOAT, + bytesPerPixel: 4, channels: 1, compressed: false, floatingPoint: true, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RG32F, { + internalFormat: WebGL2RenderingContext.RG32F, + format: WebGL2RenderingContext.RG, + type: WebGL2RenderingContext.FLOAT, + bytesPerPixel: 8, channels: 2, compressed: false, floatingPoint: true, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RGB32F, { + internalFormat: WebGL2RenderingContext.RGB32F, + format: WebGL2RenderingContext.RGB, + type: WebGL2RenderingContext.FLOAT, + bytesPerPixel: 12, channels: 3, compressed: false, floatingPoint: true, integer: false, depth: false, stencil: false, srgb: false + }], + [TextureFormat.RGBA32F, { + internalFormat: WebGL2RenderingContext.RGBA32F, + format: WebGL2RenderingContext.RGBA, + type: WebGL2RenderingContext.FLOAT, + bytesPerPixel: 16, channels: 4, compressed: false, floatingPoint: true, integer: false, depth: false, stencil: false, srgb: false + }], + + [TextureFormat.DEPTH_COMPONENT16, { + internalFormat: WebGL2RenderingContext.DEPTH_COMPONENT16, + format: WebGL2RenderingContext.DEPTH_COMPONENT, + type: WebGL2RenderingContext.UNSIGNED_SHORT, + bytesPerPixel: 2, channels: 1, compressed: false, floatingPoint: false, integer: false, depth: true, stencil: false, srgb: false + }], + [TextureFormat.DEPTH_COMPONENT24, { + internalFormat: WebGL2RenderingContext.DEPTH_COMPONENT24, + format: WebGL2RenderingContext.DEPTH_COMPONENT, + type: WebGL2RenderingContext.UNSIGNED_INT, + bytesPerPixel: 4, channels: 1, compressed: false, floatingPoint: false, integer: false, depth: true, stencil: false, srgb: false + }], + [TextureFormat.DEPTH_COMPONENT32F, { + internalFormat: WebGL2RenderingContext.DEPTH_COMPONENT32F, + format: WebGL2RenderingContext.DEPTH_COMPONENT, + type: WebGL2RenderingContext.FLOAT, + bytesPerPixel: 4, channels: 1, compressed: false, floatingPoint: true, integer: false, depth: true, stencil: false, srgb: false + }], + [TextureFormat.DEPTH24_STENCIL8, { + internalFormat: WebGL2RenderingContext.DEPTH24_STENCIL8, + format: WebGL2RenderingContext.DEPTH_STENCIL, + type: WebGL2RenderingContext.UNSIGNED_INT_24_8, + bytesPerPixel: 4, channels: 2, compressed: false, floatingPoint: false, integer: false, depth: true, stencil: true, srgb: false + }], + [TextureFormat.DEPTH32F_STENCIL8, { + internalFormat: WebGL2RenderingContext.DEPTH32F_STENCIL8, + format: WebGL2RenderingContext.DEPTH_STENCIL, + type: WebGL2RenderingContext.FLOAT_32_UNSIGNED_INT_24_8_REV, + bytesPerPixel: 8, channels: 2, compressed: false, floatingPoint: true, integer: false, depth: true, stencil: true, srgb: false + }] + ]); + + public static getFormatInfo(format: TextureFormat): FormatInfo { + const info = this.formatDatabase.get(format); + if (!info) { + throw new TextureError( + `Unsupported texture format: ${format}`, + TextureErrorCode.UNSUPPORTED_FORMAT + ); + } + return info; + } + + public static getBytesPerPixel(format: TextureFormat): number { + return this.getFormatInfo(format).bytesPerPixel; + } + + public static getChannelCount(format: TextureFormat): number { + return this.getFormatInfo(format).channels; + } + + public static isCompressed(format: TextureFormat): boolean { + return this.getFormatInfo(format).compressed; + } + + public static isFloatingPoint(format: TextureFormat): boolean { + return this.getFormatInfo(format).floatingPoint; + } + + public static isInteger(format: TextureFormat): boolean { + return this.getFormatInfo(format).integer; + } + + public static isDepth(format: TextureFormat): boolean { + return this.getFormatInfo(format).depth; + } + + public static hasStencil(format: TextureFormat): boolean { + return this.getFormatInfo(format).stencil; + } + + public static isSRGB(format: TextureFormat): boolean { + return this.getFormatInfo(format).srgb; + } + + public static getSupportedFormats(): readonly TextureFormat[] { + return Array.from(this.formatDatabase.keys()); + } +} + +export class TextureWebGLConstants { + public static readonly DIMENSION_MAP = new Map([ + [TextureDimension.TEXTURE_1D, 0x0DE0], + [TextureDimension.TEXTURE_2D, WebGL2RenderingContext.TEXTURE_2D], + [TextureDimension.TEXTURE_3D, WebGL2RenderingContext.TEXTURE_3D], + [TextureDimension.TEXTURE_CUBE, WebGL2RenderingContext.TEXTURE_CUBE_MAP], + [TextureDimension.TEXTURE_2D_ARRAY, WebGL2RenderingContext.TEXTURE_2D_ARRAY], + [TextureDimension.TEXTURE_CUBE_ARRAY, 0x9009] + ]); + + public static readonly FILTER_MAP = new Map([ + [FilterMode.NEAREST, WebGL2RenderingContext.NEAREST], + [FilterMode.LINEAR, WebGL2RenderingContext.LINEAR], + [FilterMode.NEAREST_MIPMAP_NEAREST, WebGL2RenderingContext.NEAREST_MIPMAP_NEAREST], + [FilterMode.LINEAR_MIPMAP_NEAREST, WebGL2RenderingContext.LINEAR_MIPMAP_NEAREST], + [FilterMode.NEAREST_MIPMAP_LINEAR, WebGL2RenderingContext.NEAREST_MIPMAP_LINEAR], + [FilterMode.LINEAR_MIPMAP_LINEAR, WebGL2RenderingContext.LINEAR_MIPMAP_LINEAR] + ]); + + public static readonly WRAP_MAP = new Map([ + [WrapMode.REPEAT, WebGL2RenderingContext.REPEAT], + [WrapMode.CLAMP_TO_EDGE, WebGL2RenderingContext.CLAMP_TO_EDGE], + [WrapMode.CLAMP_TO_BORDER, 0x812D], + [WrapMode.MIRRORED_REPEAT, WebGL2RenderingContext.MIRRORED_REPEAT] + ]); + + public static getDimensionConstant(dimension: TextureDimension): number { + const constant = this.DIMENSION_MAP.get(dimension); + if (constant === undefined) { + throw new TextureError( + `Unsupported texture dimension: ${dimension}`, + TextureErrorCode.UNSUPPORTED_FORMAT + ); + } + return constant; + } + + public static getFilterConstant(filter: FilterMode): number { + const constant = this.FILTER_MAP.get(filter); + if (constant === undefined) { + throw new TextureError( + `Unsupported filter mode: ${filter}`, + TextureErrorCode.INVALID_OPERATION + ); + } + return constant; + } + + public static getWrapConstant(wrap: WrapMode): number { + const constant = this.WRAP_MAP.get(wrap); + if (constant === undefined) { + throw new TextureError( + `Unsupported wrap mode: ${wrap}`, + TextureErrorCode.INVALID_OPERATION + ); + } + return constant; + } +} + +export class TextureUtils { + + public static calculateMemoryUsage( + width: number, + height: number, + depth: number, + format: TextureFormat, + mipLevels: number = 1 + ): number { + const bytesPerPixel = TextureFormatInfo.getBytesPerPixel(format); + let totalBytes = 0; + + for (let mip = 0; mip < mipLevels; mip++) { + const mipWidth = Math.max(1, width >> mip); + const mipHeight = Math.max(1, height >> mip); + const mipDepth = Math.max(1, depth >> mip); + + totalBytes += mipWidth * mipHeight * mipDepth * bytesPerPixel; + } + + return totalBytes; + } + + public static calculateMaxMipLevels(width: number, height: number, depth: number = 1): number { + const maxDimension = Math.max(width, height, depth); + return Math.floor(Math.log2(maxDimension)) + 1; + } + + public static getMipDimensions( + width: number, + height: number, + depth: number, + mipLevel: number + ): { width: number; height: number; depth: number } { + return { + width: Math.max(1, width >> mipLevel), + height: Math.max(1, height >> mipLevel), + depth: Math.max(1, depth >> mipLevel) + }; + } + + public static validateDimensions( + width: number, + height: number, + depth: number, + dimension: TextureDimension + ): void { + if (width <= 0 || height <= 0 || depth <= 0) { + throw new TextureError( + 'Texture dimensions must be positive', + TextureErrorCode.INVALID_DIMENSIONS + ); + } + + const isPowerOfTwo = (n: number) => (n & (n - 1)) === 0; + + switch (dimension) { + case TextureDimension.TEXTURE_CUBE: + if (width !== height) { + throw new TextureError( + 'Cube textures must have equal width and height', + TextureErrorCode.INVALID_DIMENSIONS + ); + } + break; + + case TextureDimension.TEXTURE_1D: + if (height !== 1 || depth !== 1) { + throw new TextureError( + '1D textures must have height and depth of 1', + TextureErrorCode.INVALID_DIMENSIONS + ); + } + break; + + case TextureDimension.TEXTURE_2D: + case TextureDimension.TEXTURE_2D_ARRAY: + if (depth < 1 && dimension === TextureDimension.TEXTURE_2D) { + throw new TextureError( + '2D textures must have depth of at least 1', + TextureErrorCode.INVALID_DIMENSIONS + ); + } + break; + } + } + + public static isFormatCompatible(format: TextureFormat, dimension: TextureDimension): boolean { + + if (dimension === TextureDimension.TEXTURE_3D && TextureFormatInfo.isDepth(format)) { + return false; + } + + if (TextureFormatInfo.isInteger(format)) { + + } + + return true; + } + + public static generateTextureId(): string { + return `tex_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + public static calculateTextureHash(options: ITextureCreateOptions): string { + const hashData = [ + options.width, + options.height, + options.depth || 1, + options.format, + options.dimension, + options.mipLevels || 1, + options.arrayLayers || 1, + options.usage, + options.colorSpace || ColorSpace.LINEAR + ].join('|'); + + return this.simpleHash(hashData); + } + + private static simpleHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return Math.abs(hash).toString(36); + } + + public static getDefaultSamplerOptions(usage: TextureUsage): ITextureSamplerOptions { + switch (usage) { + case TextureUsage.STATIC: + return { + minFilter: FilterMode.LINEAR_MIPMAP_LINEAR, + magFilter: FilterMode.LINEAR, + wrapS: WrapMode.REPEAT, + wrapT: WrapMode.REPEAT, + maxAnisotropy: 16 + }; + + case TextureUsage.RENDER_TARGET: + return { + minFilter: FilterMode.LINEAR, + magFilter: FilterMode.LINEAR, + wrapS: WrapMode.CLAMP_TO_EDGE, + wrapT: WrapMode.CLAMP_TO_EDGE + }; + + case TextureUsage.DEPTH_BUFFER: + return { + minFilter: FilterMode.NEAREST, + magFilter: FilterMode.NEAREST, + wrapS: WrapMode.CLAMP_TO_EDGE, + wrapT: WrapMode.CLAMP_TO_EDGE, + compareMode: 'COMPARE_REF_TO_TEXTURE', + compareFunc: 'LEQUAL' + }; + + default: + return { + minFilter: FilterMode.LINEAR, + magFilter: FilterMode.LINEAR, + wrapS: WrapMode.REPEAT, + wrapT: WrapMode.REPEAT + }; + } + } + + public static colorToVec4(color: string): Vec4 { + + if (color.startsWith('#')) { + const hex = color.slice(1); + const r = parseInt(hex.substr(0, 2), 16) / 255; + const g = parseInt(hex.substr(2, 2), 16) / 255; + const b = parseInt(hex.substr(4, 2), 16) / 255; + const a = hex.length === 8 ? parseInt(hex.substr(6, 2), 16) / 255 : 1; + return new Vec4(r, g, b, a); + } + + return new Vec4(1, 1, 1, 1); + } + + public static isExtensionAvailable(gl: WebGL2RenderingContext, name: string): boolean { + return gl.getExtension(name) !== null; + } + + public static getOptimalFormat( + gl: WebGL2RenderingContext, + usage: TextureUsage, + hasAlpha: boolean = false, + preferFloat: boolean = false + ): TextureFormat { + if (usage === TextureUsage.DEPTH_BUFFER) { + return TextureFormat.DEPTH_COMPONENT24; + } + + if (preferFloat) { + return hasAlpha ? TextureFormat.RGBA16F : TextureFormat.RGB16F; + } + + return hasAlpha ? TextureFormat.RGBA8 : TextureFormat.RGB8; + } +} + +export class TextureValidation { + + public static validateCreateOptions(options: ITextureCreateOptions): void { + + TextureUtils.validateDimensions( + options.width, + options.height, + options.depth || 1, + options.dimension + ); + + if (!TextureUtils.isFormatCompatible(options.format, options.dimension)) { + throw new TextureError( + `Format ${options.format} is not compatible with dimension ${options.dimension}`, + TextureErrorCode.UNSUPPORTED_FORMAT + ); + } + + if (options.mipLevels !== undefined) { + const maxMips = TextureUtils.calculateMaxMipLevels( + options.width, + options.height, + options.depth || 1 + ); + if (options.mipLevels > maxMips) { + throw new TextureError( + `Too many mip levels: ${options.mipLevels}, maximum is ${maxMips}`, + TextureErrorCode.INVALID_DIMENSIONS + ); + } + } + + if (options.arrayLayers !== undefined && options.arrayLayers < 1) { + throw new TextureError( + 'Array layers must be at least 1', + TextureErrorCode.INVALID_DIMENSIONS + ); + } + + if (options.samples !== undefined) { + const validSamples = [1, 2, 4, 8, 16]; + if (!validSamples.includes(options.samples)) { + throw new TextureError( + `Invalid sample count: ${options.samples}`, + TextureErrorCode.INVALID_DIMENSIONS + ); + } + } + } + + public static validateSamplerOptions(options: ITextureSamplerOptions): void { + + if (options.maxAnisotropy !== undefined && options.maxAnisotropy < 1) { + throw new TextureError( + 'Max anisotropy must be at least 1', + TextureErrorCode.INVALID_OPERATION + ); + } + + if (options.minLod !== undefined && options.maxLod !== undefined) { + if (options.minLod > options.maxLod) { + throw new TextureError( + 'Min LOD cannot be greater than max LOD', + TextureErrorCode.INVALID_OPERATION + ); + } + } + + if (options.borderColor) { + const color = options.borderColor; + if (color.x < 0 || color.x > 1 || color.y < 0 || color.y > 1 || + color.z < 0 || color.z > 1 || color.w < 0 || color.w > 1) { + throw new TextureError( + 'Border color components must be in range [0, 1]', + TextureErrorCode.INVALID_OPERATION + ); + } + } + } +} From 5199d00b6c4bbe13e807b9e7719cee5d418b005f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Thu, 23 Oct 2025 17:03:57 +0300 Subject: [PATCH 06/13] Add WebGL2 framebuffer abstraction --- .../core/src/renderer/webgl2/framebuffer.ts | 1564 +++++++++++++++++ 1 file changed, 1564 insertions(+) create mode 100644 packages/core/src/renderer/webgl2/framebuffer.ts diff --git a/packages/core/src/renderer/webgl2/framebuffer.ts b/packages/core/src/renderer/webgl2/framebuffer.ts new file mode 100644 index 0000000..70f246a --- /dev/null +++ b/packages/core/src/renderer/webgl2/framebuffer.ts @@ -0,0 +1,1564 @@ +import { IDisposable } from '../../types'; +import { ITexture, IBindableTarget } from './texture/interfaces'; + +type Nominal = T & { readonly __brand: K }; + +export type FramebufferId = Nominal; +export type RenderbufferId = Nominal; + +export type GLTextureTarget = + | WebGL2RenderingContext['TEXTURE_2D'] + | WebGL2RenderingContext['TEXTURE_CUBE_MAP'] + | WebGL2RenderingContext['TEXTURE_2D_ARRAY'] + | WebGL2RenderingContext['TEXTURE_3D']; + +export type GLTextureFormat = + | WebGL2RenderingContext['RGB'] + | WebGL2RenderingContext['RGBA'] + | WebGL2RenderingContext['RGBA8'] + | WebGL2RenderingContext['RGBA16F'] + | WebGL2RenderingContext['RGBA32F'] + | WebGL2RenderingContext['RGB8'] + | WebGL2RenderingContext['RGB16F'] + | WebGL2RenderingContext['RGB32F'] + | WebGL2RenderingContext['R8'] + | WebGL2RenderingContext['R16F'] + | WebGL2RenderingContext['R32F'] + | WebGL2RenderingContext['RG8'] + | WebGL2RenderingContext['RG16F'] + | WebGL2RenderingContext['RG32F'] + | WebGL2RenderingContext['DEPTH_COMPONENT16'] + | WebGL2RenderingContext['DEPTH_COMPONENT24'] + | WebGL2RenderingContext['DEPTH_COMPONENT32F'] + | WebGL2RenderingContext['DEPTH24_STENCIL8'] + | WebGL2RenderingContext['DEPTH32F_STENCIL8']; + +export type GLAttachment = + | WebGL2RenderingContext['COLOR_ATTACHMENT0'] + | WebGL2RenderingContext['COLOR_ATTACHMENT1'] + | WebGL2RenderingContext['COLOR_ATTACHMENT2'] + | WebGL2RenderingContext['COLOR_ATTACHMENT3'] + | WebGL2RenderingContext['COLOR_ATTACHMENT4'] + | WebGL2RenderingContext['COLOR_ATTACHMENT5'] + | WebGL2RenderingContext['COLOR_ATTACHMENT6'] + | WebGL2RenderingContext['COLOR_ATTACHMENT7'] + | WebGL2RenderingContext['COLOR_ATTACHMENT8'] + | WebGL2RenderingContext['COLOR_ATTACHMENT9'] + | WebGL2RenderingContext['COLOR_ATTACHMENT10'] + | WebGL2RenderingContext['COLOR_ATTACHMENT11'] + | WebGL2RenderingContext['COLOR_ATTACHMENT12'] + | WebGL2RenderingContext['COLOR_ATTACHMENT13'] + | WebGL2RenderingContext['COLOR_ATTACHMENT14'] + | WebGL2RenderingContext['COLOR_ATTACHMENT15'] + | WebGL2RenderingContext['DEPTH_ATTACHMENT'] + | WebGL2RenderingContext['STENCIL_ATTACHMENT'] + | WebGL2RenderingContext['DEPTH_STENCIL_ATTACHMENT']; + +export type GLFilterMode = WebGL2RenderingContext['NEAREST'] | WebGL2RenderingContext['LINEAR']; + +export type GLWrapMode = + | WebGL2RenderingContext['CLAMP_TO_EDGE'] + | WebGL2RenderingContext['REPEAT'] + | WebGL2RenderingContext['MIRRORED_REPEAT']; + +export type FramebufferStatus = + | WebGL2RenderingContext['FRAMEBUFFER_COMPLETE'] + | WebGL2RenderingContext['FRAMEBUFFER_INCOMPLETE_ATTACHMENT'] + | WebGL2RenderingContext['FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT'] + | WebGL2RenderingContext['FRAMEBUFFER_INCOMPLETE_DIMENSIONS'] + | WebGL2RenderingContext['FRAMEBUFFER_UNSUPPORTED'] + | WebGL2RenderingContext['FRAMEBUFFER_INCOMPLETE_MULTISAMPLE']; + +export type ErrorCode = + | 'INVALID_OPERATION' + | 'FRAMEBUFFER_ALREADY_DISPOSED' + | 'TEXTURE_ALREADY_DISPOSED' + | 'RENDERBUFFER_ALREADY_DISPOSED' + | 'OUT_OF_MEMORY' + | 'INVALID_VALUE' + | 'CONTEXT_LOST' + | 'UNSUPPORTED_OPERATION' + | 'INCOMPLETE_FRAMEBUFFER' + | 'INVALID_ATTACHMENT' + | 'ATTACHMENT_MISMATCH' + | 'MAX_COLOR_ATTACHMENTS_EXCEEDED'; + +export class FramebufferError extends Error { + constructor( + public readonly message: string, + public readonly code: ErrorCode, + public readonly cause?: Error + ) { + super(`[WebGL2 Framebuffer] ${code}: ${message}`); + Object.setPrototypeOf(this, new.target.prototype); + Error.captureStackTrace?.(this, this.constructor); + } +} + +export interface TextureOptions { + readonly width: number; + readonly height: number; + readonly format?: GLTextureFormat; + readonly internalFormat?: GLTextureFormat; + readonly type?: GLenum; + readonly minFilter?: GLFilterMode; + readonly magFilter?: GLFilterMode; + readonly wrapS?: GLWrapMode; + readonly wrapT?: GLWrapMode; + readonly generateMipmap?: boolean; + readonly samples?: number; + readonly label?: string; +} + +export interface RenderbufferOptions { + readonly width: number; + readonly height: number; + readonly internalFormat: GLTextureFormat; + readonly samples?: number; + readonly label?: string; +} + +export interface AttachmentConfig { + readonly attachment: GLAttachment; + readonly texture?: ITexture; + readonly renderbuffer?: IRenderbuffer; + readonly level?: number; + readonly layer?: number; +} + +export interface FramebufferOptions { + readonly width: number; + readonly height: number; + readonly colorAttachments?: readonly AttachmentConfig[]; + readonly depthAttachment?: AttachmentConfig; + readonly stencilAttachment?: AttachmentConfig; + readonly depthStencilAttachment?: AttachmentConfig; + readonly label?: string; +} + +export interface IBindableTarget { + readonly bind: () => T; + readonly unbind: () => T; +} + +export interface ITexture extends IDisposable, IBindableTarget { + readonly id: TextureId; + readonly target: GLTextureTarget; + readonly width: number; + readonly height: number; + readonly format: GLTextureFormat; + readonly internalFormat: GLTextureFormat; + readonly type: GLenum; + readonly samples: number; + readonly label: string | null; + + readonly resize: (width: number, height: number) => ITexture; + readonly generateMipmap: () => ITexture; + readonly setData: (data: TexImageSource | ArrayBufferView | null, level?: number) => ITexture; + readonly getPixels: (output: T, level?: number) => T; +} + +export interface IRenderbuffer extends IDisposable, IBindableTarget { + readonly id: RenderbufferId; + readonly width: number; + readonly height: number; + readonly internalFormat: GLTextureFormat; + readonly samples: number; + readonly label: string | null; + + readonly resize: (width: number, height: number, samples?: number) => IRenderbuffer; +} + +export interface IFramebuffer extends IDisposable, IBindableTarget { + readonly id: FramebufferId; + readonly width: number; + readonly height: number; + readonly label: string | null; + readonly isComplete: boolean; + readonly status: FramebufferStatus; + readonly colorAttachments: readonly ITexture[]; + readonly depthAttachment: ITexture | IRenderbuffer | null; + readonly stencilAttachment: ITexture | IRenderbuffer | null; + readonly depthStencilAttachment: ITexture | IRenderbuffer | null; + + readonly attachTexture: ( + attachment: GLAttachment, + texture: ITexture, + level?: number, + layer?: number + ) => IFramebuffer; + + readonly attachRenderbuffer: ( + attachment: GLAttachment, + renderbuffer: IRenderbuffer + ) => IFramebuffer; + + readonly detach: (attachment: GLAttachment) => IFramebuffer; + readonly resize: (width: number, height: number) => IFramebuffer; + readonly clear: ( + color?: [number, number, number, number], + depth?: number, + stencil?: number + ) => IFramebuffer; + readonly readPixels: ( + output: T, + x?: number, + y?: number, + width?: number, + height?: number, + attachment?: GLAttachment + ) => T; + + readonly blit: ( + source: IFramebuffer, + srcRect?: [number, number, number, number], + dstRect?: [number, number, number, number], + mask?: GLbitfield, + filter?: GLFilterMode + ) => IFramebuffer; +} + +export interface IFramebufferFactory { + readonly createTexture: (target: GLTextureTarget, options: TextureOptions) => ITexture; + readonly createTexture2D: (options: TextureOptions) => ITexture; + readonly createTextureCube: (options: TextureOptions) => ITexture; + readonly createTexture2DArray: (options: TextureOptions & { depth: number }) => ITexture; + readonly createTexture3D: (options: TextureOptions & { depth: number }) => ITexture; + + readonly createRenderbuffer: (options: RenderbufferOptions) => IRenderbuffer; + + readonly createFramebuffer: (options: FramebufferOptions) => IFramebuffer; + readonly createColorFramebuffer: ( + width: number, + height: number, + format?: GLTextureFormat, + samples?: number + ) => IFramebuffer; + + readonly createDepthFramebuffer: ( + width: number, + height: number, + format?: GLTextureFormat, + samples?: number + ) => IFramebuffer; + + readonly createFramebufferWithDepth: ( + width: number, + height: number, + colorFormat?: GLTextureFormat, + depthFormat?: GLTextureFormat, + samples?: number + ) => IFramebuffer; +} + +const createGLConstants = ( + gl: WebGL2RenderingContext +): Readonly<{ + TEXTURE_2D: T; + TEXTURE_CUBE_MAP: T; + TEXTURE_2D_ARRAY: T; + TEXTURE_3D: T; + + RGB: T; + RGBA: T; + RGBA8: T; + RGBA16F: T; + RGBA32F: T; + RGB8: T; + RGB16F: T; + RGB32F: T; + R8: T; + R16F: T; + R32F: T; + RG8: T; + RG16F: T; + RG32F: T; + DEPTH_COMPONENT16: T; + DEPTH_COMPONENT24: T; + DEPTH_COMPONENT32F: T; + DEPTH24_STENCIL8: T; + DEPTH32F_STENCIL8: T; + + COLOR_ATTACHMENT0: T; + DEPTH_ATTACHMENT: T; + STENCIL_ATTACHMENT: T; + DEPTH_STENCIL_ATTACHMENT: T; + + NEAREST: T; + LINEAR: T; + + CLAMP_TO_EDGE: T; + REPEAT: T; + MIRRORED_REPEAT: T; + + UNSIGNED_BYTE: T; + FLOAT: T; + HALF_FLOAT: T; + UNSIGNED_SHORT: T; + UNSIGNED_INT: T; + UNSIGNED_INT_24_8: T; + FLOAT_32_UNSIGNED_INT_24_8_REV: T; + + FRAMEBUFFER_COMPLETE: T; + FRAMEBUFFER_INCOMPLETE_ATTACHMENT: T; + FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: T; + FRAMEBUFFER_INCOMPLETE_DIMENSIONS: T; + FRAMEBUFFER_UNSUPPORTED: T; + FRAMEBUFFER_INCOMPLETE_MULTISAMPLE: T; +}> => { + return Object.freeze({ + TEXTURE_2D: gl.TEXTURE_2D as T, + TEXTURE_CUBE_MAP: gl.TEXTURE_CUBE_MAP as T, + TEXTURE_2D_ARRAY: gl.TEXTURE_2D_ARRAY as T, + TEXTURE_3D: gl.TEXTURE_3D as T, + + RGB: gl.RGB as T, + RGBA: gl.RGBA as T, + RGBA8: gl.RGBA8 as T, + RGBA16F: gl.RGBA16F as T, + RGBA32F: gl.RGBA32F as T, + RGB8: gl.RGB8 as T, + RGB16F: gl.RGB16F as T, + RGB32F: gl.RGB32F as T, + R8: gl.R8 as T, + R16F: gl.R16F as T, + R32F: gl.R32F as T, + RG8: gl.RG8 as T, + RG16F: gl.RG16F as T, + RG32F: gl.RG32F as T, + DEPTH_COMPONENT16: gl.DEPTH_COMPONENT16 as T, + DEPTH_COMPONENT24: gl.DEPTH_COMPONENT24 as T, + DEPTH_COMPONENT32F: gl.DEPTH_COMPONENT32F as T, + DEPTH24_STENCIL8: gl.DEPTH24_STENCIL8 as T, + DEPTH32F_STENCIL8: gl.DEPTH32F_STENCIL8 as T, + + COLOR_ATTACHMENT0: gl.COLOR_ATTACHMENT0 as T, + DEPTH_ATTACHMENT: gl.DEPTH_ATTACHMENT as T, + STENCIL_ATTACHMENT: gl.STENCIL_ATTACHMENT as T, + DEPTH_STENCIL_ATTACHMENT: gl.DEPTH_STENCIL_ATTACHMENT as T, + + NEAREST: gl.NEAREST as T, + LINEAR: gl.LINEAR as T, + + CLAMP_TO_EDGE: gl.CLAMP_TO_EDGE as T, + REPEAT: gl.REPEAT as T, + MIRRORED_REPEAT: gl.MIRRORED_REPEAT as T, + + UNSIGNED_BYTE: gl.UNSIGNED_BYTE as T, + FLOAT: gl.FLOAT as T, + HALF_FLOAT: gl.HALF_FLOAT as T, + UNSIGNED_SHORT: gl.UNSIGNED_SHORT as T, + UNSIGNED_INT: gl.UNSIGNED_INT as T, + UNSIGNED_INT_24_8: gl.UNSIGNED_INT_24_8 as T, + FLOAT_32_UNSIGNED_INT_24_8_REV: gl.FLOAT_32_UNSIGNED_INT_24_8_REV as T, + + FRAMEBUFFER_COMPLETE: gl.FRAMEBUFFER_COMPLETE as T, + FRAMEBUFFER_INCOMPLETE_ATTACHMENT: gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT as T, + FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: + gl.FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT as T, + FRAMEBUFFER_INCOMPLETE_DIMENSIONS: gl.FRAMEBUFFER_INCOMPLETE_DIMENSIONS as T, + FRAMEBUFFER_UNSUPPORTED: gl.FRAMEBUFFER_UNSUPPORTED as T, + FRAMEBUFFER_INCOMPLETE_MULTISAMPLE: gl.FRAMEBUFFER_INCOMPLETE_MULTISAMPLE as T, + }); +}; + +const getTextureTypeForFormat = (gl: WebGL2RenderingContext, format: GLTextureFormat): GLenum => { + switch (format) { + case gl.RGBA8: + case gl.RGB8: + case gl.RG8: + case gl.R8: + return gl.UNSIGNED_BYTE; + case gl.RGBA16F: + case gl.RGB16F: + case gl.RG16F: + case gl.R16F: + return gl.HALF_FLOAT; + case gl.RGBA32F: + case gl.RGB32F: + case gl.RG32F: + case gl.R32F: + case gl.DEPTH_COMPONENT32F: + return gl.FLOAT; + case gl.DEPTH_COMPONENT16: + return gl.UNSIGNED_SHORT; + case gl.DEPTH_COMPONENT24: + return gl.UNSIGNED_INT; + case gl.DEPTH24_STENCIL8: + return gl.UNSIGNED_INT_24_8; + case gl.DEPTH32F_STENCIL8: + return gl.FLOAT_32_UNSIGNED_INT_24_8_REV; + default: + return gl.UNSIGNED_BYTE; + } +}; + +const getPixelFormatForInternalFormat = ( + gl: WebGL2RenderingContext, + internalFormat: GLTextureFormat +): GLTextureFormat => { + switch (internalFormat) { + case gl.RGBA8: + case gl.RGBA16F: + case gl.RGBA32F: + return gl.RGBA as GLTextureFormat; + case gl.RGB8: + case gl.RGB16F: + case gl.RGB32F: + return gl.RGB as GLTextureFormat; + case gl.RG8: + case gl.RG16F: + case gl.RG32F: + return gl.RG as GLTextureFormat; + case gl.R8: + case gl.R16F: + case gl.R32F: + return gl.RED as GLTextureFormat; + case gl.DEPTH_COMPONENT16: + case gl.DEPTH_COMPONENT24: + case gl.DEPTH_COMPONENT32F: + return gl.DEPTH_COMPONENT as GLTextureFormat; + case gl.DEPTH24_STENCIL8: + case gl.DEPTH32F_STENCIL8: + return gl.DEPTH_STENCIL as GLTextureFormat; + default: + return gl.RGBA as GLTextureFormat; + } +}; + +const isDepthFormat = (gl: WebGL2RenderingContext, format: GLTextureFormat): boolean => { + return ( + format === gl.DEPTH_COMPONENT16 || + format === gl.DEPTH_COMPONENT24 || + format === gl.DEPTH_COMPONENT32F || + format === gl.DEPTH24_STENCIL8 || + format === gl.DEPTH32F_STENCIL8 + ); +}; + +const isStencilFormat = (gl: WebGL2RenderingContext, format: GLTextureFormat): boolean => { + return format === gl.DEPTH24_STENCIL8 || format === gl.DEPTH32F_STENCIL8; +}; + +const validateAttachmentConfig = (gl: WebGL2RenderingContext, config: AttachmentConfig): void => { + if (!config.texture && !config.renderbuffer) { + throw new FramebufferError( + 'Attachment config must specify either texture or renderbuffer', + 'INVALID_ATTACHMENT' + ); + } + + if (config.texture && config.renderbuffer) { + throw new FramebufferError( + 'Attachment config cannot specify both texture and renderbuffer', + 'INVALID_ATTACHMENT' + ); + } + + if (config.texture && config.texture.isDisposed) { + throw new FramebufferError('Cannot attach disposed texture', 'TEXTURE_ALREADY_DISPOSED'); + } + + if (config.renderbuffer && config.renderbuffer.isDisposed) { + throw new FramebufferError( + 'Cannot attach disposed renderbuffer', + 'RENDERBUFFER_ALREADY_DISPOSED' + ); + } +}; + +const getFramebufferStatusString = ( + gl: WebGL2RenderingContext, + status: FramebufferStatus +): string => { + switch (status) { + case gl.FRAMEBUFFER_COMPLETE: + return 'FRAMEBUFFER_COMPLETE'; + case gl.FRAMEBUFFER_INCOMPLETE_ATTACHMENT: + return 'FRAMEBUFFER_INCOMPLETE_ATTACHMENT'; + case gl.FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: + return 'FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT'; + case gl.FRAMEBUFFER_INCOMPLETE_DIMENSIONS: + return 'FRAMEBUFFER_INCOMPLETE_DIMENSIONS'; + case gl.FRAMEBUFFER_UNSUPPORTED: + return 'FRAMEBUFFER_UNSUPPORTED'; + case gl.FRAMEBUFFER_INCOMPLETE_MULTISAMPLE: + return 'FRAMEBUFFER_INCOMPLETE_MULTISAMPLE'; + default: + return `UNKNOWN_STATUS_${status}`; + } +}; + +export class Texture implements ITexture { + readonly #gl: WebGL2RenderingContext; + readonly #id: WebGLTexture; + readonly #target: GLTextureTarget; + readonly #constants: ReturnType; + + #width: number; + #height: number; + #format: GLTextureFormat; + #internalFormat: GLTextureFormat; + #type: GLenum; + #samples: number; + #label: string | null; + #isDisposed: boolean = false; + + public get id(): TextureId { + this.#throwIfDisposed(); + return this.#id as TextureId; + } + + public get target(): GLTextureTarget { + return this.#target; + } + + public get width(): number { + return this.#width; + } + + public get height(): number { + return this.#height; + } + + public get format(): GLTextureFormat { + return this.#format; + } + + public get internalFormat(): GLTextureFormat { + return this.#internalFormat; + } + + public get type(): GLenum { + return this.#type; + } + + public get samples(): number { + return this.#samples; + } + + public get label(): string | null { + return this.#label; + } + + public get isDisposed(): boolean { + return this.#isDisposed; + } + + constructor(gl: WebGL2RenderingContext, target: GLTextureTarget, options: TextureOptions) { + const { + width, + height, + format, + internalFormat = format ?? gl.RGBA8, + type, + minFilter = gl.LINEAR, + magFilter = gl.LINEAR, + wrapS = gl.CLAMP_TO_EDGE, + wrapT = gl.CLAMP_TO_EDGE, + generateMipmap = false, + samples = 0, + label = null, + } = options; + + this.#gl = gl; + this.#target = target; + this.#width = width; + this.#height = height; + this.#internalFormat = internalFormat; + this.#format = format ?? getPixelFormatForInternalFormat(gl, internalFormat); + this.#type = type ?? getTextureTypeForFormat(gl, internalFormat); + this.#samples = samples; + this.#label = label; + this.#constants = createGLConstants(gl); + + const texture = gl.createTexture(); + if (!texture) { + throw new FramebufferError('Failed to create WebGLTexture', 'OUT_OF_MEMORY'); + } + this.#id = texture; + + this.bind(); + + if (samples > 0) { + throw new FramebufferError( + 'Multisampled textures should be handled via renderbuffers for better compatibility', + 'UNSUPPORTED_OPERATION' + ); + } else if (target === gl.TEXTURE_2D) { + gl.texStorage2D(target, 1, internalFormat, width, height); + } else if (target === gl.TEXTURE_CUBE_MAP) { + gl.texStorage2D(target, 1, internalFormat, width, height); + } + + if (samples === 0) { + gl.texParameteri(target, gl.TEXTURE_MIN_FILTER, minFilter); + gl.texParameteri(target, gl.TEXTURE_MAG_FILTER, magFilter); + gl.texParameteri(target, gl.TEXTURE_WRAP_S, wrapS); + gl.texParameteri(target, gl.TEXTURE_WRAP_T, wrapT); + + if (generateMipmap) { + this.generateMipmap(); + } + } + + this.unbind(); + + const debugExt = this.#gl.getExtension('KHR_debug'); + if (debugExt && typeof debugExt.labelObject === 'function' && label) { + debugExt.labelObject(debugExt.TEXTURE, this.#id, label); + } + } + + public bind = (): ITexture => { + this.#throwIfDisposed(); + this.#gl.bindTexture(this.#target, this.#id); + return this; + }; + + public unbind = (): ITexture => { + this.#throwIfDisposed(); + this.#gl.bindTexture(this.#target, null); + return this; + }; + + public resize = (width: number, height: number): ITexture => { + this.#throwIfDisposed(); + + if (width <= 0 || height <= 0) { + throw new FramebufferError(`Invalid dimensions: ${width}x${height}`, 'INVALID_VALUE'); + } + + if (this.#samples > 0) { + throw new FramebufferError( + 'Cannot resize multisampled textures directly', + 'INVALID_OPERATION' + ); + } + + this.#width = width; + this.#height = height; + + this.bind(); + + if (this.#target === this.#gl.TEXTURE_2D) { + this.#gl.texStorage2D(this.#target, 1, this.#internalFormat, width, height); + } + + this.unbind(); + return this; + }; + + public generateMipmap = (): ITexture => { + this.#throwIfDisposed(); + + if (this.#samples > 0) { + throw new FramebufferError( + 'Cannot generate mipmaps for multisampled textures', + 'INVALID_OPERATION' + ); + } + + this.bind(); + this.#gl.generateMipmap(this.#target); + this.unbind(); + return this; + }; + + public setData = ( + data: TexImageSource | ArrayBufferView | null, + level: number = 0 + ): ITexture => { + this.#throwIfDisposed(); + + if (this.#samples > 0) { + throw new FramebufferError( + 'Cannot set data on multisampled textures', + 'INVALID_OPERATION' + ); + } + + this.bind(); + + if (this.#target === this.#gl.TEXTURE_2D) { + if (data === null) { + this.#gl.texSubImage2D( + this.#target, + level, + 0, + 0, + this.#width, + this.#height, + this.#format, + this.#type, + null + ); + } else if ( + data instanceof HTMLImageElement || + data instanceof HTMLCanvasElement || + data instanceof HTMLVideoElement || + data instanceof ImageBitmap || + data instanceof ImageData + ) { + this.#gl.texSubImage2D(this.#target, level, 0, 0, this.#format, this.#type, data); + } else if (ArrayBuffer.isView(data)) { + this.#gl.texSubImage2D( + this.#target, + level, + 0, + 0, + this.#width, + this.#height, + this.#format, + this.#type, + data + ); + } + } + + this.unbind(); + return this; + }; + + public getPixels = (output: T, level: number = 0): T => { + this.#throwIfDisposed(); + + if (this.#samples > 0) { + throw new FramebufferError( + 'Cannot read pixels from multisampled textures directly', + 'INVALID_OPERATION' + ); + } + + throw new FramebufferError( + 'Direct texture pixel reading not supported. Use framebuffer readPixels instead.', + 'UNSUPPORTED_OPERATION' + ); + }; + + public dispose = (): void => { + if (this.#isDisposed) return; + + this.#gl.deleteTexture(this.#id); + this.#isDisposed = true; + }; + + #throwIfDisposed = (): void => { + if (this.#isDisposed) { + throw new FramebufferError('Texture has been disposed', 'TEXTURE_ALREADY_DISPOSED'); + } + }; +} + +export class Renderbuffer implements IRenderbuffer { + readonly #gl: WebGL2RenderingContext; + readonly #id: WebGLRenderbuffer; + readonly #constants: ReturnType; + + #width: number; + #height: number; + #internalFormat: GLTextureFormat; + #samples: number; + #label: string | null; + #isDisposed: boolean = false; + + public get id(): RenderbufferId { + this.#throwIfDisposed(); + return this.#id as RenderbufferId; + } + + public get width(): number { + return this.#width; + } + + public get height(): number { + return this.#height; + } + + public get internalFormat(): GLTextureFormat { + return this.#internalFormat; + } + + public get samples(): number { + return this.#samples; + } + + public get label(): string | null { + return this.#label; + } + + public get isDisposed(): boolean { + return this.#isDisposed; + } + + constructor(gl: WebGL2RenderingContext, options: RenderbufferOptions) { + const { width, height, internalFormat, samples = 0, label = null } = options; + + this.#gl = gl; + this.#width = width; + this.#height = height; + this.#internalFormat = internalFormat; + this.#samples = samples; + this.#label = label; + this.#constants = createGLConstants(gl); + + const renderbuffer = gl.createRenderbuffer(); + if (!renderbuffer) { + throw new FramebufferError('Failed to create WebGLRenderbuffer', 'OUT_OF_MEMORY'); + } + this.#id = renderbuffer; + + this.bind(); + + if (samples > 0) { + gl.renderbufferStorageMultisample( + gl.RENDERBUFFER, + samples, + internalFormat, + width, + height + ); + } else { + gl.renderbufferStorage(gl.RENDERBUFFER, internalFormat, width, height); + } + + this.unbind(); + + const debugExt = this.#gl.getExtension('KHR_debug'); + if (debugExt && typeof debugExt.labelObject === 'function' && label) { + debugExt.labelObject(debugExt.RENDERBUFFER, this.#id, label); + } + } + + public bind = (): IRenderbuffer => { + this.#throwIfDisposed(); + this.#gl.bindRenderbuffer(this.#gl.RENDERBUFFER, this.#id); + return this; + }; + + public unbind = (): IRenderbuffer => { + this.#throwIfDisposed(); + this.#gl.bindRenderbuffer(this.#gl.RENDERBUFFER, null); + return this; + }; + + public resize = (width: number, height: number, samples?: number): IRenderbuffer => { + this.#throwIfDisposed(); + + if (width <= 0 || height <= 0) { + throw new FramebufferError(`Invalid dimensions: ${width}x${height}`, 'INVALID_VALUE'); + } + + this.#width = width; + this.#height = height; + if (samples !== undefined) { + this.#samples = samples; + } + + this.bind(); + + if (this.#samples > 0) { + this.#gl.renderbufferStorageMultisample( + this.#gl.RENDERBUFFER, + this.#samples, + this.#internalFormat, + width, + height + ); + } else { + this.#gl.renderbufferStorage( + this.#gl.RENDERBUFFER, + this.#internalFormat, + width, + height + ); + } + + this.unbind(); + return this; + }; + + public dispose = (): void => { + if (this.#isDisposed) return; + + this.#gl.deleteRenderbuffer(this.#id); + this.#isDisposed = true; + }; + + #throwIfDisposed = (): void => { + if (this.#isDisposed) { + throw new FramebufferError( + 'Renderbuffer has been disposed', + 'RENDERBUFFER_ALREADY_DISPOSED' + ); + } + }; +} + +export class Framebuffer implements IFramebuffer { + readonly #gl: WebGL2RenderingContext; + readonly #id: WebGLFramebuffer; + readonly #constants: ReturnType; + + #width: number; + #height: number; + #label: string | null; + #isDisposed: boolean = false; + #colorAttachments: ITexture[] = []; + #depthAttachment: ITexture | IRenderbuffer | null = null; + #stencilAttachment: ITexture | IRenderbuffer | null = null; + #depthStencilAttachment: ITexture | IRenderbuffer | null = null; + + public get id(): FramebufferId { + this.#throwIfDisposed(); + return this.#id as FramebufferId; + } + + public get width(): number { + return this.#width; + } + + public get height(): number { + return this.#height; + } + + public get label(): string | null { + return this.#label; + } + + public get isDisposed(): boolean { + return this.#isDisposed; + } + + public get colorAttachments(): readonly ITexture[] { + return [...this.#colorAttachments]; + } + + public get depthAttachment(): ITexture | IRenderbuffer | null { + return this.#depthAttachment; + } + + public get stencilAttachment(): ITexture | IRenderbuffer | null { + return this.#stencilAttachment; + } + + public get depthStencilAttachment(): ITexture | IRenderbuffer | null { + return this.#depthStencilAttachment; + } + + public get isComplete(): boolean { + this.#throwIfDisposed(); + this.bind(); + const status = this.#gl.checkFramebufferStatus(this.#gl.FRAMEBUFFER); + this.unbind(); + return status === this.#gl.FRAMEBUFFER_COMPLETE; + } + + public get status(): FramebufferStatus { + this.#throwIfDisposed(); + this.bind(); + const status = this.#gl.checkFramebufferStatus(this.#gl.FRAMEBUFFER) as FramebufferStatus; + this.unbind(); + return status; + } + + constructor(gl: WebGL2RenderingContext, options: FramebufferOptions) { + const { + width, + height, + colorAttachments = [], + depthAttachment, + stencilAttachment, + depthStencilAttachment, + label = null, + } = options; + + this.#gl = gl; + this.#width = width; + this.#height = height; + this.#label = label; + this.#constants = createGLConstants(gl); + + const framebuffer = gl.createFramebuffer(); + if (!framebuffer) { + throw new FramebufferError('Failed to create WebGLFramebuffer', 'OUT_OF_MEMORY'); + } + this.#id = framebuffer; + + this.bind(); + + for (const config of colorAttachments) { + this.#attachInternal(config); + } + + if (depthAttachment) { + this.#attachInternal(depthAttachment); + } + + if (stencilAttachment) { + this.#attachInternal(stencilAttachment); + } + + if (depthStencilAttachment) { + this.#attachInternal(depthStencilAttachment); + } + + const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + if (status !== gl.FRAMEBUFFER_COMPLETE) { + this.unbind(); + throw new FramebufferError( + `Framebuffer incomplete: ${getFramebufferStatusString(gl, status as FramebufferStatus)}`, + 'INCOMPLETE_FRAMEBUFFER' + ); + } + + this.unbind(); + + const debugExt = this.#gl.getExtension('KHR_debug'); + if (debugExt && typeof debugExt.labelObject === 'function' && label) { + debugExt.labelObject(debugExt.FRAMEBUFFER, this.#id, label); + } + } + + public bind = (): IFramebuffer => { + this.#throwIfDisposed(); + this.#gl.bindFramebuffer(this.#gl.FRAMEBUFFER, this.#id); + this.#gl.viewport(0, 0, this.#width, this.#height); + return this; + }; + + public unbind = (): IFramebuffer => { + this.#throwIfDisposed(); + this.#gl.bindFramebuffer(this.#gl.FRAMEBUFFER, null); + return this; + }; + + public attachTexture = ( + attachment: GLAttachment, + texture: ITexture, + level: number = 0, + layer?: number + ): IFramebuffer => { + this.#throwIfDisposed(); + + if (texture.isDisposed) { + throw new FramebufferError( + 'Cannot attach disposed texture', + 'TEXTURE_ALREADY_DISPOSED' + ); + } + + this.bind(); + + if (layer !== undefined && texture.target === this.#gl.TEXTURE_2D_ARRAY) { + this.#gl.framebufferTextureLayer( + this.#gl.FRAMEBUFFER, + attachment, + texture.id as WebGLTexture, + level, + layer + ); + } else { + this.#gl.framebufferTexture2D( + this.#gl.FRAMEBUFFER, + attachment, + texture.target, + texture.id as WebGLTexture, + level + ); + } + + this.#updateAttachmentReferences(attachment, texture); + this.unbind(); + return this; + }; + + public attachRenderbuffer = ( + attachment: GLAttachment, + renderbuffer: IRenderbuffer + ): IFramebuffer => { + this.#throwIfDisposed(); + + if (renderbuffer.isDisposed) { + throw new FramebufferError( + 'Cannot attach disposed renderbuffer', + 'RENDERBUFFER_ALREADY_DISPOSED' + ); + } + + this.bind(); + this.#gl.framebufferRenderbuffer( + this.#gl.FRAMEBUFFER, + attachment, + this.#gl.RENDERBUFFER, + renderbuffer.id as WebGLRenderbuffer + ); + + this.#updateAttachmentReferences(attachment, renderbuffer); + this.unbind(); + return this; + }; + + public detach = (attachment: GLAttachment): IFramebuffer => { + this.#throwIfDisposed(); + + this.bind(); + this.#gl.framebufferTexture2D( + this.#gl.FRAMEBUFFER, + attachment, + this.#gl.TEXTURE_2D, + null, + 0 + ); + + this.#updateAttachmentReferences(attachment, null); + this.unbind(); + return this; + }; + + public resize = (width: number, height: number): IFramebuffer => { + this.#throwIfDisposed(); + + if (width <= 0 || height <= 0) { + throw new FramebufferError(`Invalid dimensions: ${width}x${height}`, 'INVALID_VALUE'); + } + + this.#width = width; + this.#height = height; + + for (const texture of this.#colorAttachments) { + if (!texture.isDisposed) { + texture.resize(width, height); + } + } + + if (this.#depthAttachment && !this.#depthAttachment.isDisposed) { + this.#depthAttachment.resize(width, height); + } + + if (this.#stencilAttachment && !this.#stencilAttachment.isDisposed) { + this.#stencilAttachment.resize(width, height); + } + + if (this.#depthStencilAttachment && !this.#depthStencilAttachment.isDisposed) { + this.#depthStencilAttachment.resize(width, height); + } + + return this; + }; + + public clear = ( + color?: [number, number, number, number], + depth?: number, + stencil?: number + ): IFramebuffer => { + this.#throwIfDisposed(); + + this.bind(); + + let mask = 0; + + if (color !== undefined && this.#colorAttachments.length > 0) { + this.#gl.clearColor(color[0], color[1], color[2], color[3]); + mask |= this.#gl.COLOR_BUFFER_BIT; + } + + if (depth !== undefined && (this.#depthAttachment || this.#depthStencilAttachment)) { + this.#gl.clearDepth(depth); + mask |= this.#gl.DEPTH_BUFFER_BIT; + } + + if (stencil !== undefined && (this.#stencilAttachment || this.#depthStencilAttachment)) { + this.#gl.clearStencil(stencil); + mask |= this.#gl.STENCIL_BUFFER_BIT; + } + + if (mask > 0) { + this.#gl.clear(mask); + } + + this.unbind(); + return this; + }; + + public readPixels = ( + output: T, + x: number = 0, + y: number = 0, + width: number = this.#width, + height: number = this.#height, + attachment: GLAttachment = this.#gl.COLOR_ATTACHMENT0 + ): T => { + this.#throwIfDisposed(); + + this.bind(); + + if (attachment >= this.#gl.COLOR_ATTACHMENT0 && attachment <= this.#gl.COLOR_ATTACHMENT15) { + this.#gl.readBuffer(attachment); + } + + const format = this.#gl.RGBA; + const type = this.#gl.UNSIGNED_BYTE; + + this.#gl.readPixels(x, y, width, height, format, type, output); + + this.unbind(); + return output; + }; + + public blit = ( + source: IFramebuffer, + srcRect: [number, number, number, number] = [0, 0, source.width, source.height], + dstRect: [number, number, number, number] = [0, 0, this.#width, this.#height], + mask: GLbitfield = this.#gl.COLOR_BUFFER_BIT, + filter: GLFilterMode = this.#gl.NEAREST + ): IFramebuffer => { + this.#throwIfDisposed(); + + if (source.isDisposed) { + throw new FramebufferError( + 'Cannot blit from disposed framebuffer', + 'FRAMEBUFFER_ALREADY_DISPOSED' + ); + } + + this.#gl.bindFramebuffer(this.#gl.READ_FRAMEBUFFER, source.id as WebGLFramebuffer); + this.#gl.bindFramebuffer(this.#gl.DRAW_FRAMEBUFFER, this.#id); + + this.#gl.blitFramebuffer( + srcRect[0], + srcRect[1], + srcRect[2], + srcRect[3], + dstRect[0], + dstRect[1], + dstRect[2], + dstRect[3], + mask, + filter + ); + + this.#gl.bindFramebuffer(this.#gl.READ_FRAMEBUFFER, null); + this.#gl.bindFramebuffer(this.#gl.DRAW_FRAMEBUFFER, null); + + return this; + }; + + public dispose = (): void => { + if (this.#isDisposed) return; + + this.#gl.deleteFramebuffer(this.#id); + this.#isDisposed = true; + + this.#colorAttachments.length = 0; + this.#depthAttachment = null; + this.#stencilAttachment = null; + this.#depthStencilAttachment = null; + }; + + #attachInternal = (config: AttachmentConfig): void => { + validateAttachmentConfig(this.#gl, config); + + if (config.texture) { + this.attachTexture(config.attachment, config.texture, config.level, config.layer); + } else if (config.renderbuffer) { + this.attachRenderbuffer(config.attachment, config.renderbuffer); + } + }; + + #updateAttachmentReferences = ( + attachment: GLAttachment, + resource: ITexture | IRenderbuffer | null + ): void => { + const gl = this.#gl; + + if (attachment >= gl.COLOR_ATTACHMENT0 && attachment <= gl.COLOR_ATTACHMENT15) { + const index = attachment - gl.COLOR_ATTACHMENT0; + + while (this.#colorAttachments.length <= index) { + this.#colorAttachments.push(null as any); + } + + this.#colorAttachments[index] = resource as ITexture; + } else if (attachment === gl.DEPTH_ATTACHMENT) { + this.#depthAttachment = resource; + } else if (attachment === gl.STENCIL_ATTACHMENT) { + this.#stencilAttachment = resource; + } else if (attachment === gl.DEPTH_STENCIL_ATTACHMENT) { + this.#depthStencilAttachment = resource; + } + }; + + #throwIfDisposed = (): void => { + if (this.#isDisposed) { + throw new FramebufferError( + 'Framebuffer has been disposed', + 'FRAMEBUFFER_ALREADY_DISPOSED' + ); + } + }; +} + +export class FramebufferFactory implements IFramebufferFactory { + readonly #gl: WebGL2RenderingContext; + readonly #constants: ReturnType; + + constructor(gl: WebGL2RenderingContext) { + this.#gl = gl; + this.#constants = createGLConstants(gl); + } + + public createTexture = (target: GLTextureTarget, options: TextureOptions): ITexture => { + return new Texture(this.#gl, target, options); + }; + + public createTexture2D = (options: TextureOptions): ITexture => { + return this.createTexture(this.#gl.TEXTURE_2D, options); + }; + + public createTextureCube = (options: TextureOptions): ITexture => { + return this.createTexture(this.#gl.TEXTURE_CUBE_MAP, options); + }; + + public createTexture2DArray = (options: TextureOptions & { depth: number }): ITexture => { + return this.createTexture(this.#gl.TEXTURE_2D_ARRAY, options); + }; + + public createTexture3D = (options: TextureOptions & { depth: number }): ITexture => { + return this.createTexture(this.#gl.TEXTURE_3D, options); + }; + + public createRenderbuffer = (options: RenderbufferOptions): IRenderbuffer => { + return new Renderbuffer(this.#gl, options); + }; + + public createFramebuffer = (options: FramebufferOptions): IFramebuffer => { + return new Framebuffer(this.#gl, options); + }; + + public createColorFramebuffer = ( + width: number, + height: number, + format: GLTextureFormat = this.#gl.RGBA8, + samples: number = 0 + ): IFramebuffer => { + const colorTexture = this.createTexture2D({ + width, + height, + internalFormat: format, + samples, + label: 'ColorFramebuffer_ColorAttachment', + }); + + return this.createFramebuffer({ + width, + height, + colorAttachments: [ + { + attachment: this.#gl.COLOR_ATTACHMENT0, + texture: colorTexture, + }, + ], + label: 'ColorFramebuffer', + }); + }; + + public createDepthFramebuffer = ( + width: number, + height: number, + format: GLTextureFormat = this.#gl.DEPTH_COMPONENT24, + samples: number = 0 + ): IFramebuffer => { + if (samples > 0) { + const depthRenderbuffer = this.createRenderbuffer({ + width, + height, + internalFormat: format, + samples, + label: 'DepthFramebuffer_DepthAttachment', + }); + + return this.createFramebuffer({ + width, + height, + depthAttachment: { + attachment: this.#gl.DEPTH_ATTACHMENT, + renderbuffer: depthRenderbuffer, + }, + label: 'DepthFramebuffer', + }); + } else { + const depthTexture = this.createTexture2D({ + width, + height, + internalFormat: format, + minFilter: this.#gl.NEAREST, + magFilter: this.#gl.NEAREST, + label: 'DepthFramebuffer_DepthAttachment', + }); + + return this.createFramebuffer({ + width, + height, + depthAttachment: { + attachment: this.#gl.DEPTH_ATTACHMENT, + texture: depthTexture, + }, + label: 'DepthFramebuffer', + }); + } + }; + + public createFramebufferWithDepth = ( + width: number, + height: number, + colorFormat: GLTextureFormat = this.#gl.RGBA8, + depthFormat: GLTextureFormat = this.#gl.DEPTH_COMPONENT24, + samples: number = 0 + ): IFramebuffer => { + const colorTexture = this.createTexture2D({ + width, + height, + internalFormat: colorFormat, + samples, + label: 'FramebufferWithDepth_ColorAttachment', + }); + + if (samples > 0) { + const depthRenderbuffer = this.createRenderbuffer({ + width, + height, + internalFormat: depthFormat, + samples, + label: 'FramebufferWithDepth_DepthAttachment', + }); + + return this.createFramebuffer({ + width, + height, + colorAttachments: [ + { + attachment: this.#gl.COLOR_ATTACHMENT0, + texture: colorTexture, + }, + ], + depthAttachment: { + attachment: this.#gl.DEPTH_ATTACHMENT, + renderbuffer: depthRenderbuffer, + }, + label: 'FramebufferWithDepth', + }); + } else { + const depthTexture = this.createTexture2D({ + width, + height, + internalFormat: depthFormat, + minFilter: this.#gl.NEAREST, + magFilter: this.#gl.NEAREST, + label: 'FramebufferWithDepth_DepthAttachment', + }); + + return this.createFramebuffer({ + width, + height, + colorAttachments: [ + { + attachment: this.#gl.COLOR_ATTACHMENT0, + texture: colorTexture, + }, + ], + depthAttachment: { + attachment: this.#gl.DEPTH_ATTACHMENT, + texture: depthTexture, + }, + label: 'FramebufferWithDepth', + }); + } + }; +} + +export const createFramebufferFactory = (gl: WebGL2RenderingContext): IFramebufferFactory => { + return new FramebufferFactory(gl); +}; + +export const createRenderTarget = ( + gl: WebGL2RenderingContext, + width: number, + height: number, + options: { + colorFormat?: GLTextureFormat; + depthFormat?: GLTextureFormat; + samples?: number; + useDepth?: boolean; + useStencil?: boolean; + label?: string; + } = {} +): IFramebuffer => { + const { + colorFormat = gl.RGBA8, + depthFormat = gl.DEPTH24_STENCIL8, + samples = 0, + useDepth = true, + useStencil = false, + label = 'RenderTarget', + } = options; + + const factory = createFramebufferFactory(gl); + + if (useDepth || useStencil) { + const actualDepthFormat = + useDepth && useStencil + ? depthFormat + : useDepth + ? (gl.DEPTH_COMPONENT24 as GLTextureFormat) + : (gl.STENCIL_INDEX8 as GLTextureFormat); + + return factory.createFramebufferWithDepth( + width, + height, + colorFormat, + actualDepthFormat, + samples + ); + } else { + return factory.createColorFramebuffer(width, height, colorFormat, samples); + } +}; + +export const createShadowMap = ( + gl: WebGL2RenderingContext, + size: number, + format: GLTextureFormat = gl.DEPTH_COMPONENT24 +): IFramebuffer => { + const factory = createFramebufferFactory(gl); + return factory.createDepthFramebuffer(size, size, format); +}; + +export const createMultisampledRenderTarget = ( + gl: WebGL2RenderingContext, + width: number, + height: number, + samples: number, + colorFormat: GLTextureFormat = gl.RGBA8, + depthFormat: GLTextureFormat = gl.DEPTH24_STENCIL8 +): { msaaTarget: IFramebuffer; resolveTarget: IFramebuffer } => { + const factory = createFramebufferFactory(gl); + + const msaaTarget = factory.createFramebufferWithDepth( + width, + height, + colorFormat, + depthFormat, + samples + ); + + const resolveTarget = factory.createFramebufferWithDepth( + width, + height, + colorFormat, + depthFormat, + 0 + ); + + return { msaaTarget, resolveTarget }; +}; From 3b69e95de8e3f4d34ecfa036d237bdf01ad8ad8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Thu, 23 Oct 2025 17:12:10 +0300 Subject: [PATCH 07/13] Add WebGL2 mesh module with interfaces and utilities --- .../core/src/renderer/webgl2/mesh/index.ts | 22 + .../src/renderer/webgl2/mesh/interfaces.ts | 353 ++++++++++ .../core/src/renderer/webgl2/mesh/manager.ts | 149 ++++ .../core/src/renderer/webgl2/mesh/utils.ts | 648 ++++++++++++++++++ .../src/renderer/webgl2/mesh/vertex-buffer.ts | 88 +++ 5 files changed, 1260 insertions(+) create mode 100644 packages/core/src/renderer/webgl2/mesh/index.ts create mode 100644 packages/core/src/renderer/webgl2/mesh/interfaces.ts create mode 100644 packages/core/src/renderer/webgl2/mesh/manager.ts create mode 100644 packages/core/src/renderer/webgl2/mesh/utils.ts create mode 100644 packages/core/src/renderer/webgl2/mesh/vertex-buffer.ts diff --git a/packages/core/src/renderer/webgl2/mesh/index.ts b/packages/core/src/renderer/webgl2/mesh/index.ts new file mode 100644 index 0000000..5e485c6 --- /dev/null +++ b/packages/core/src/renderer/webgl2/mesh/index.ts @@ -0,0 +1,22 @@ +export * from './interfaces'; + +export { WebGLVertexBuffer as VertexBuffer } from './vertex-buffer'; +export { WebGLIndexBuffer as IndexBuffer } from './index-buffer'; + +export * from './utils'; + +export { MeshManager } from './manager'; +export type { IMeshData } from './manager'; + +export type { + IVertexBuffer, + IIndexBuffer, + IGeometry, + IVertexArrayObject, + IVertexLayout, + IVertexAttributeDescriptor, + VertexAttributeType, + IndexType, + BufferUsage, + PrimitiveTopology +} from './interfaces'; diff --git a/packages/core/src/renderer/webgl2/mesh/interfaces.ts b/packages/core/src/renderer/webgl2/mesh/interfaces.ts new file mode 100644 index 0000000..410f9ac --- /dev/null +++ b/packages/core/src/renderer/webgl2/mesh/interfaces.ts @@ -0,0 +1,353 @@ +import { Vec2, Vec3, Vec4, Mat4 } from '@axrone/numeric'; +import { ByteBuffer } from '@axrone/utility'; +import { IBindableTarget } from '../texture/interfaces'; + +export const enum VertexAttributeType { + POSITION = 'POSITION', + NORMAL = 'NORMAL', + TANGENT = 'TANGENT', + TEXCOORD_0 = 'TEXCOORD_0', + TEXCOORD_1 = 'TEXCOORD_1', + TEXCOORD_2 = 'TEXCOORD_2', + TEXCOORD_3 = 'TEXCOORD_3', + COLOR_0 = 'COLOR_0', + COLOR_1 = 'COLOR_1', + JOINTS_0 = 'JOINTS_0', + WEIGHTS_0 = 'WEIGHTS_0', + CUSTOM_0 = 'CUSTOM_0', + CUSTOM_1 = 'CUSTOM_1', + CUSTOM_2 = 'CUSTOM_2', + CUSTOM_3 = 'CUSTOM_3' +} + +export const enum VertexDataType { + BYTE = 'BYTE', + UNSIGNED_BYTE = 'UNSIGNED_BYTE', + SHORT = 'SHORT', + UNSIGNED_SHORT = 'UNSIGNED_SHORT', + INT = 'INT', + UNSIGNED_INT = 'UNSIGNED_INT', + FLOAT = 'FLOAT', + HALF_FLOAT = 'HALF_FLOAT' +} + +export const enum PrimitiveTopology { + POINTS = 'POINTS', + LINES = 'LINES', + LINE_STRIP = 'LINE_STRIP', + LINE_LOOP = 'LINE_LOOP', + TRIANGLES = 'TRIANGLES', + TRIANGLE_STRIP = 'TRIANGLE_STRIP', + TRIANGLE_FAN = 'TRIANGLE_FAN' +} + +export const enum BufferUsage { + STATIC_DRAW = 'STATIC_DRAW', + DYNAMIC_DRAW = 'DYNAMIC_DRAW', + STREAM_DRAW = 'STREAM_DRAW', + STATIC_READ = 'STATIC_READ', + DYNAMIC_READ = 'DYNAMIC_READ', + STREAM_READ = 'STREAM_READ', + STATIC_COPY = 'STATIC_COPY', + DYNAMIC_COPY = 'DYNAMIC_COPY', + STREAM_COPY = 'STREAM_COPY' +} + +export const enum IndexType { + UNSIGNED_BYTE = 'UNSIGNED_BYTE', + UNSIGNED_SHORT = 'UNSIGNED_SHORT', + UNSIGNED_INT = 'UNSIGNED_INT' +} + +export const enum VertexBufferError { + NONE = 0, + INVALID_DATA_FORMAT = 1, + BUFFER_CREATION_FAILED = 2, + BUFFER_NOT_INITIALIZED = 3, + UPDATE_FAILED = 4, + INVALID_LAYOUT = 5, + ATTRIBUTE_MISMATCH = 6 +} + +export const enum IndexBufferError { + NONE = 0, + INVALID_DATA_FORMAT = 1, + BUFFER_CREATION_FAILED = 2, + BUFFER_NOT_INITIALIZED = 3, + UPDATE_FAILED = 4, + INVALID_INDEX_TYPE = 5 +} + +export interface IVertexBufferConfig { + readonly data?: ArrayBufferView | ByteBuffer; + readonly usage?: BufferUsage; + readonly layout: IVertexLayout; +} + +export interface IIndexBufferConfig { + readonly data?: ArrayBufferView | ByteBuffer; + readonly usage?: BufferUsage; + readonly indexType?: IndexType; +} + +export interface IVertexAttributeDescriptor { + readonly type: VertexAttributeType; + readonly dataType: VertexDataType; + readonly componentCount: number; + readonly normalized: boolean; + readonly offset: number; + readonly stride: number; + readonly divisor?: number; +} + +export interface IVertexLayout { + readonly attributes: readonly IVertexAttributeDescriptor[]; + readonly stride: number; + readonly vertexCount: number; +} + +export interface IVertexBuffer extends IBindableTarget { + readonly id: string; + readonly nativeHandle: WebGLBuffer; + readonly usage: BufferUsage; + readonly size: number; + readonly vertexCount: number; + readonly layout: IVertexLayout; + readonly isDisposed: boolean; + + update(data: ArrayBuffer | ArrayBufferView, offset?: number): this; + resize(newSize: number): this; + + dispose(): void; +} + +export interface IIndexBuffer extends IBindableTarget { + readonly id: string; + readonly nativeHandle: WebGLBuffer; + readonly usage: BufferUsage; + readonly size: number; + readonly indexCount: number; + readonly indexType: IndexType; + readonly isDisposed: boolean; + + update(data: ArrayBuffer | ArrayBufferView, offset?: number): this; + resize(newSize: number): this; + + dispose(): void; +} + +export interface IVertexArrayObject extends IBindableTarget { + readonly id: string; + readonly nativeHandle: WebGLVertexArrayObject; + readonly vertexBuffers: readonly IVertexBuffer[]; + readonly indexBuffer: IIndexBuffer | null; + readonly isDisposed: boolean; + + addVertexBuffer(buffer: IVertexBuffer): void; + setIndexBuffer(buffer: IIndexBuffer): void; + removeVertexBuffer(buffer: IVertexBuffer): boolean; + clearVertexBuffers(): void; + + dispose(): void; +} + +export interface IBoundingBox { + readonly min: Vec3; + readonly max: Vec3; + readonly center: Vec3; + readonly size: Vec3; + readonly radius: number; +} + +export interface IBoundingSphere { + readonly center: Vec3; + readonly radius: number; +} + +export interface IGeometry { + readonly id: string; + readonly primitiveTopology: PrimitiveTopology; + readonly vertexCount: number; + readonly indexCount: number; + readonly boundingBox: IBoundingBox; + readonly boundingSphere: IBoundingSphere; + readonly vertexArrayObject: IVertexArrayObject; + readonly vertexBuffers: readonly IVertexBuffer[]; + readonly indexBuffer: IIndexBuffer | null; + readonly isDisposed: boolean; + + hasAttribute(type: VertexAttributeType): boolean; + getAttribute(type: VertexAttributeType): IVertexBuffer | null; + getAttributeData(type: VertexAttributeType): Float32Array | null; + + computeBounds(): void; + generateNormals(): void; + generateTangents(): void; + + dispose(): void; +} + +export interface IMesh { + readonly id: string; + readonly name: string; + readonly geometry: IGeometry; + readonly materialIndex: number; + readonly transform: Mat4; + readonly boundingBox: IBoundingBox; + readonly boundingSphere: IBoundingSphere; + readonly visible: boolean; + readonly castShadows: boolean; + readonly receiveShadows: boolean; + readonly isDisposed: boolean; + + setTransform(transform: Mat4): void; + translate(offset: Vec3): void; + rotate(rotation: Vec3): void; + scale(scale: Vec3): void; + + render(): void; + renderInstanced(count: number): void; + + updateBounds(): void; + + dispose(): void; +} + +export interface IVertexBufferCreateOptions { + readonly data: ArrayBufferView | ByteBuffer; + readonly layout: IVertexLayout; + readonly usage: BufferUsage; + readonly label?: string; +} + +export interface IIndexBufferCreateOptions { + readonly data: ArrayBufferView | ByteBuffer; + readonly indexType: IndexType; + readonly usage: BufferUsage; + readonly label?: string; +} + +export interface IGeometryCreateOptions { + readonly primitiveTopology: PrimitiveTopology; + readonly vertexBuffers: IVertexBuffer[]; + readonly indexBuffer?: IIndexBuffer; + readonly computeBounds?: boolean; + readonly label?: string; +} + +export interface IMeshCreateOptions { + readonly name: string; + readonly geometry: IGeometry; + readonly materialIndex?: number; + readonly transform?: Mat4; + readonly visible?: boolean; + readonly castShadows?: boolean; + readonly receiveShadows?: boolean; +} + +export interface IMeshManager { + + createVertexBuffer(options: IVertexBufferCreateOptions): IVertexBuffer; + createIndexBuffer(options: IIndexBufferCreateOptions): IIndexBuffer; + createVertexArrayObject(): IVertexArrayObject; + + createGeometry(options: IGeometryCreateOptions): IGeometry; + createQuadGeometry(): IGeometry; + createCubeGeometry(size?: number): IGeometry; + createSphereGeometry(radius?: number, segments?: number): IGeometry; + createPlaneGeometry(width?: number, height?: number): IGeometry; + + createMesh(options: IMeshCreateOptions): IMesh; + + getMesh(id: string): IMesh | null; + cacheMesh(id: string, mesh: IMesh): void; + removeCachedMesh(id: string): boolean; + clearCache(): void; + + getStats(): IMeshManagerStats; + optimizeMemory(): void; + dispose(): void; +} + +export interface IMeshManagerStats { + readonly totalMeshes: number; + readonly totalGeometries: number; + readonly totalVertexBuffers: number; + readonly totalIndexBuffers: number; + readonly totalVertices: number; + readonly totalIndices: number; + readonly memoryUsage: number; + readonly cacheHitRate: number; +} + +export interface IPrimitiveGenerator { + generateQuad(): { + vertices: Float32Array; + indices: Uint16Array; + layout: IVertexLayout; + }; + + generateCube(size: number): { + vertices: Float32Array; + indices: Uint16Array; + layout: IVertexLayout; + }; + + generateSphere(radius: number, segments: number): { + vertices: Float32Array; + indices: Uint16Array; + layout: IVertexLayout; + }; + + generatePlane(width: number, height: number, subdivisions?: number): { + vertices: Float32Array; + indices: Uint16Array; + layout: IVertexLayout; + }; +} + +export class MeshError extends Error { + constructor( + message: string, + public readonly code: MeshErrorCode, + public readonly meshId?: string, + public readonly cause?: Error + ) { + super(`[Mesh] ${code}: ${message}`); + this.name = 'MeshError'; + } +} + +export const enum MeshErrorCode { + INVALID_VERTEX_DATA = 'INVALID_VERTEX_DATA', + INVALID_INDEX_DATA = 'INVALID_INDEX_DATA', + BUFFER_CREATION_FAILED = 'BUFFER_CREATION_FAILED', + VAO_CREATION_FAILED = 'VAO_CREATION_FAILED', + ATTRIBUTE_NOT_FOUND = 'ATTRIBUTE_NOT_FOUND', + INVALID_LAYOUT = 'INVALID_LAYOUT', + ALREADY_DISPOSED = 'ALREADY_DISPOSED', + DISPOSED_RESOURCE_ACCESS = 'DISPOSED_RESOURCE_ACCESS', + CONTEXT_LOST = 'CONTEXT_LOST', + OUT_OF_MEMORY = 'OUT_OF_MEMORY', + INVALID_OPERATION = 'INVALID_OPERATION' +} + +export interface IMeshBuilder { + vertices(data: ArrayBufferView | number[]): IMeshBuilder; + indices(data: ArrayBufferView | number[]): IMeshBuilder; + attribute(type: VertexAttributeType, dataType: VertexDataType, componentCount: number, normalized?: boolean): IMeshBuilder; + topology(topology: PrimitiveTopology): IMeshBuilder; + usage(usage: BufferUsage): IMeshBuilder; + label(name: string): IMeshBuilder; + + transform(matrix: Mat4): IMeshBuilder; + translate(offset: Vec3): IMeshBuilder; + rotate(rotation: Vec3): IMeshBuilder; + scale(scale: Vec3): IMeshBuilder; + + computeBounds(enabled: boolean): IMeshBuilder; + generateNormals(enabled: boolean): IMeshBuilder; + generateTangents(enabled: boolean): IMeshBuilder; + + build(): IMesh; +} diff --git a/packages/core/src/renderer/webgl2/mesh/manager.ts b/packages/core/src/renderer/webgl2/mesh/manager.ts new file mode 100644 index 0000000..c37fbbc --- /dev/null +++ b/packages/core/src/renderer/webgl2/mesh/manager.ts @@ -0,0 +1,149 @@ +import { IBuffer, IBufferFactory, createBufferFactory } from '../buffer'; +import { IGeometryBuffers } from '../../../geometry/primitives/types'; +import { + createSphere, + createBox, + createPlane, + createCylinder, + createCapsule, + createTorus +} from '../../../geometry/primitives'; + +export interface IMeshData { + readonly id: string; + readonly vertexBuffer: IBuffer; + readonly indexBuffer: IBuffer | null; + readonly vertexCount: number; + readonly indexCount: number; + readonly topology: 'triangles' | 'lines' | 'points'; +} + +export class MeshManager { + private readonly gl: WebGL2RenderingContext; + private readonly bufferFactory: IBufferFactory; + private readonly meshCache = new Map(); + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.bufferFactory = createBufferFactory(gl); + } + + public createMeshFromGeometry(id: string, geometryBuffers: IGeometryBuffers): IMeshData { + + if (this.meshCache.has(id)) { + return this.meshCache.get(id)!; + } + + const vertexData = geometryBuffers.vertices.toUint8Array(); + const vertexBuffer = this.bufferFactory.createArrayBufferFromData( + vertexData as unknown as BufferSource, + this.gl.STATIC_DRAW + ); + + let indexBuffer: IBuffer | null = null; + if (geometryBuffers.layout.indexCount > 0) { + const indexData = geometryBuffers.indices.toUint8Array(); + indexBuffer = this.bufferFactory.createElementArrayBufferFromData( + indexData as unknown as BufferSource, + this.gl.STATIC_DRAW + ); + } + + const mesh: IMeshData = { + id, + vertexBuffer, + indexBuffer, + vertexCount: geometryBuffers.layout.vertexCount, + indexCount: geometryBuffers.layout.indexCount, + topology: geometryBuffers.layout.primitiveType as any + }; + + this.meshCache.set(id, mesh); + return mesh; + } + + public createSphereMesh(id: string, radius: number = 1, segments: number = 32): IMeshData { + const geometryBuffers = createSphere({ + radius, + widthSegments: segments, + heightSegments: segments, + generateNormals: true, + generateTexCoords: true, + generateTangents: false + }); + return this.createMeshFromGeometry(id, geometryBuffers); + } + + public createBoxMesh(id: string, width: number = 1, height: number = 1, depth: number = 1): IMeshData { + const geometryBuffers = createBox({ + width, + height, + depth, + generateNormals: true, + generateTexCoords: true, + generateTangents: false + }); + return this.createMeshFromGeometry(id, geometryBuffers); + } + + public createPlaneMesh(id: string, width: number = 1, height: number = 1): IMeshData { + const geometryBuffers = createPlane({ + width, + height, + generateNormals: true, + generateTexCoords: true, + generateTangents: false + }); + return this.createMeshFromGeometry(id, geometryBuffers); + } + + public getMesh(id: string): IMeshData | null { + return this.meshCache.get(id) || null; + } + + public renderMesh(mesh: IMeshData): void { + + mesh.vertexBuffer.bind(); + + if (mesh.indexBuffer) { + + mesh.indexBuffer.bind(); + + const mode = this.getGLTopology(mesh.topology); + const indexType = this.gl.UNSIGNED_SHORT; + this.gl.drawElements(mode, mesh.indexCount, indexType, 0); + + mesh.indexBuffer.unbind(); + } else { + + const mode = this.getGLTopology(mesh.topology); + this.gl.drawArrays(mode, 0, mesh.vertexCount); + } + + mesh.vertexBuffer.unbind(); + } + + private getGLTopology(topology: string): number { + switch (topology) { + case 'triangles': return this.gl.TRIANGLES; + case 'lines': return this.gl.LINES; + case 'points': return this.gl.POINTS; + default: return this.gl.TRIANGLES; + } + } + + public getStats() { + return { + totalMeshes: this.meshCache.size, + cachedMeshes: Array.from(this.meshCache.keys()) + }; + } + + public dispose(): void { + for (const mesh of this.meshCache.values()) { + mesh.vertexBuffer.dispose(); + mesh.indexBuffer?.dispose(); + } + this.meshCache.clear(); + } +} diff --git a/packages/core/src/renderer/webgl2/mesh/utils.ts b/packages/core/src/renderer/webgl2/mesh/utils.ts new file mode 100644 index 0000000..fe23f25 --- /dev/null +++ b/packages/core/src/renderer/webgl2/mesh/utils.ts @@ -0,0 +1,648 @@ +import { Vec3, Mat4 } from '@axrone/numeric'; +import { + VertexAttributeType, + VertexDataType, + PrimitiveTopology, + BufferUsage, + IndexType, + IVertexAttributeDescriptor, + IVertexLayout, + IBoundingBox, + IBoundingSphere, + MeshError, + MeshErrorCode, +} from './interfaces'; + +export class MeshWebGLConstants { + public static readonly VERTEX_DATA_TYPE_MAP = new Map([ + [VertexDataType.BYTE, WebGL2RenderingContext.BYTE], + [VertexDataType.UNSIGNED_BYTE, WebGL2RenderingContext.UNSIGNED_BYTE], + [VertexDataType.SHORT, WebGL2RenderingContext.SHORT], + [VertexDataType.UNSIGNED_SHORT, WebGL2RenderingContext.UNSIGNED_SHORT], + [VertexDataType.INT, WebGL2RenderingContext.INT], + [VertexDataType.UNSIGNED_INT, WebGL2RenderingContext.UNSIGNED_INT], + [VertexDataType.FLOAT, WebGL2RenderingContext.FLOAT], + [VertexDataType.HALF_FLOAT, WebGL2RenderingContext.HALF_FLOAT], + ]); + + public static readonly PRIMITIVE_TOPOLOGY_MAP = new Map([ + [PrimitiveTopology.POINTS, WebGL2RenderingContext.POINTS], + [PrimitiveTopology.LINES, WebGL2RenderingContext.LINES], + [PrimitiveTopology.LINE_STRIP, WebGL2RenderingContext.LINE_STRIP], + [PrimitiveTopology.LINE_LOOP, WebGL2RenderingContext.LINE_LOOP], + [PrimitiveTopology.TRIANGLES, WebGL2RenderingContext.TRIANGLES], + [PrimitiveTopology.TRIANGLE_STRIP, WebGL2RenderingContext.TRIANGLE_STRIP], + [PrimitiveTopology.TRIANGLE_FAN, WebGL2RenderingContext.TRIANGLE_FAN], + ]); + + public static readonly BUFFER_USAGE_MAP = new Map([ + [BufferUsage.STATIC_DRAW, WebGL2RenderingContext.STATIC_DRAW], + [BufferUsage.DYNAMIC_DRAW, WebGL2RenderingContext.DYNAMIC_DRAW], + [BufferUsage.STREAM_DRAW, WebGL2RenderingContext.STREAM_DRAW], + [BufferUsage.STATIC_READ, WebGL2RenderingContext.STATIC_READ], + [BufferUsage.DYNAMIC_READ, WebGL2RenderingContext.DYNAMIC_READ], + [BufferUsage.STREAM_READ, WebGL2RenderingContext.STREAM_READ], + [BufferUsage.STATIC_COPY, WebGL2RenderingContext.STATIC_COPY], + [BufferUsage.DYNAMIC_COPY, WebGL2RenderingContext.DYNAMIC_COPY], + [BufferUsage.STREAM_COPY, WebGL2RenderingContext.STREAM_COPY], + ]); + + public static readonly INDEX_TYPE_MAP = new Map([ + [IndexType.UNSIGNED_BYTE, WebGL2RenderingContext.UNSIGNED_BYTE], + [IndexType.UNSIGNED_SHORT, WebGL2RenderingContext.UNSIGNED_SHORT], + [IndexType.UNSIGNED_INT, WebGL2RenderingContext.UNSIGNED_INT], + ]); + + public static getVertexDataTypeConstant(type: VertexDataType): number { + const constant = this.VERTEX_DATA_TYPE_MAP.get(type); + if (constant === undefined) { + throw new MeshError( + `Unsupported vertex data type: ${type}`, + MeshErrorCode.INVALID_VERTEX_DATA + ); + } + return constant; + } + + public static getPrimitiveTopologyConstant(topology: PrimitiveTopology): number { + const constant = this.PRIMITIVE_TOPOLOGY_MAP.get(topology); + if (constant === undefined) { + throw new MeshError( + `Unsupported primitive topology: ${topology}`, + MeshErrorCode.INVALID_OPERATION + ); + } + return constant; + } + + public static getBufferUsageConstant(usage: BufferUsage): number { + const constant = this.BUFFER_USAGE_MAP.get(usage); + if (constant === undefined) { + throw new MeshError( + `Unsupported buffer usage: ${usage}`, + MeshErrorCode.INVALID_OPERATION + ); + } + return constant; + } + + public static getIndexTypeConstant(type: IndexType): number { + const constant = this.INDEX_TYPE_MAP.get(type); + if (constant === undefined) { + throw new MeshError( + `Unsupported index type: ${type}`, + MeshErrorCode.INVALID_INDEX_DATA + ); + } + return constant; + } +} + +export class VertexAttributeInfo { + private static readonly ATTRIBUTE_LOCATIONS = new Map([ + [VertexAttributeType.POSITION, 0], + [VertexAttributeType.NORMAL, 1], + [VertexAttributeType.TANGENT, 2], + [VertexAttributeType.TEXCOORD_0, 3], + [VertexAttributeType.TEXCOORD_1, 4], + [VertexAttributeType.TEXCOORD_2, 5], + [VertexAttributeType.TEXCOORD_3, 6], + [VertexAttributeType.COLOR_0, 7], + [VertexAttributeType.COLOR_1, 8], + [VertexAttributeType.JOINTS_0, 9], + [VertexAttributeType.WEIGHTS_0, 10], + [VertexAttributeType.CUSTOM_0, 11], + [VertexAttributeType.CUSTOM_1, 12], + [VertexAttributeType.CUSTOM_2, 13], + [VertexAttributeType.CUSTOM_3, 14], + ]); + + private static readonly DATA_TYPE_SIZES = new Map([ + [VertexDataType.BYTE, 1], + [VertexDataType.UNSIGNED_BYTE, 1], + [VertexDataType.SHORT, 2], + [VertexDataType.UNSIGNED_SHORT, 2], + [VertexDataType.INT, 4], + [VertexDataType.UNSIGNED_INT, 4], + [VertexDataType.FLOAT, 4], + [VertexDataType.HALF_FLOAT, 2], + ]); + + public static getAttributeLocation(type: VertexAttributeType): number { + const location = this.ATTRIBUTE_LOCATIONS.get(type); + if (location === undefined) { + throw new MeshError( + `Unknown attribute type: ${type}`, + MeshErrorCode.ATTRIBUTE_NOT_FOUND + ); + } + return location; + } + + public static getDataTypeSize(type: VertexDataType): number { + const size = this.DATA_TYPE_SIZES.get(type); + if (size === undefined) { + throw new MeshError(`Unknown data type: ${type}`, MeshErrorCode.INVALID_VERTEX_DATA); + } + return size; + } + + public static getAttributeByteSize(descriptor: IVertexAttributeDescriptor): number { + return this.getDataTypeSize(descriptor.dataType) * descriptor.componentCount; + } + + public static getDefaultComponentCount(type: VertexAttributeType): number { + switch (type) { + case VertexAttributeType.POSITION: + case VertexAttributeType.NORMAL: + case VertexAttributeType.TANGENT: + return 3; + case VertexAttributeType.TEXCOORD_0: + case VertexAttributeType.TEXCOORD_1: + case VertexAttributeType.TEXCOORD_2: + case VertexAttributeType.TEXCOORD_3: + return 2; + case VertexAttributeType.COLOR_0: + case VertexAttributeType.COLOR_1: + case VertexAttributeType.JOINTS_0: + case VertexAttributeType.WEIGHTS_0: + return 4; + default: + return 1; + } + } + + public static getDefaultDataType(type: VertexAttributeType): VertexDataType { + switch (type) { + case VertexAttributeType.JOINTS_0: + return VertexDataType.UNSIGNED_SHORT; + case VertexAttributeType.COLOR_0: + case VertexAttributeType.COLOR_1: + return VertexDataType.UNSIGNED_BYTE; + default: + return VertexDataType.FLOAT; + } + } + + public static shouldNormalize(type: VertexAttributeType, dataType: VertexDataType): boolean { + if (type === VertexAttributeType.COLOR_0 || type === VertexAttributeType.COLOR_1) { + return dataType === VertexDataType.UNSIGNED_BYTE; + } + if (type === VertexAttributeType.WEIGHTS_0) { + return ( + dataType === VertexDataType.UNSIGNED_BYTE || + dataType === VertexDataType.UNSIGNED_SHORT + ); + } + return false; + } +} + +export class MeshUtils { + + public static calculateLayoutStride(attributes: readonly IVertexAttributeDescriptor[]): number { + let maxEnd = 0; + for (const attr of attributes) { + const end = attr.offset + VertexAttributeInfo.getAttributeByteSize(attr); + maxEnd = Math.max(maxEnd, end); + } + return maxEnd; + } + + public static validateVertexLayout(layout: IVertexLayout): void { + if (layout.attributes.length === 0) { + throw new MeshError( + 'Vertex layout must have at least one attribute', + MeshErrorCode.INVALID_LAYOUT + ); + } + + for (let i = 0; i < layout.attributes.length; i++) { + const attr1 = layout.attributes[i]; + const attr1End = attr1.offset + VertexAttributeInfo.getAttributeByteSize(attr1); + + for (let j = i + 1; j < layout.attributes.length; j++) { + const attr2 = layout.attributes[j]; + const attr2End = attr2.offset + VertexAttributeInfo.getAttributeByteSize(attr2); + + if (!(attr1End <= attr2.offset || attr2End <= attr1.offset)) { + throw new MeshError( + `Overlapping vertex attributes: ${attr1.type} and ${attr2.type}`, + MeshErrorCode.INVALID_LAYOUT + ); + } + } + } + + const calculatedStride = this.calculateLayoutStride(layout.attributes); + if (layout.stride < calculatedStride) { + throw new MeshError( + `Invalid stride: ${layout.stride}, minimum required: ${calculatedStride}`, + MeshErrorCode.INVALID_LAYOUT + ); + } + } + + public static createPositionLayout(): IVertexLayout { + return { + attributes: [ + { + type: VertexAttributeType.POSITION, + dataType: VertexDataType.FLOAT, + componentCount: 3, + normalized: false, + offset: 0, + stride: 12, + }, + ], + stride: 12, + vertexCount: 0, + }; + } + + public static createStandardLayout(): IVertexLayout { + return { + attributes: [ + { + type: VertexAttributeType.POSITION, + dataType: VertexDataType.FLOAT, + componentCount: 3, + normalized: false, + offset: 0, + stride: 32, + }, + { + type: VertexAttributeType.NORMAL, + dataType: VertexDataType.FLOAT, + componentCount: 3, + normalized: false, + offset: 12, + stride: 32, + }, + { + type: VertexAttributeType.TEXCOORD_0, + dataType: VertexDataType.FLOAT, + componentCount: 2, + normalized: false, + offset: 24, + stride: 32, + }, + ], + stride: 32, + vertexCount: 0, + }; + } + + public static calculateVertexMemoryUsage(layout: IVertexLayout, vertexCount: number): number { + return layout.stride * vertexCount; + } + + public static calculateIndexMemoryUsage(indexType: IndexType, indexCount: number): number { + const bytesPerIndex = + indexType === IndexType.UNSIGNED_BYTE + ? 1 + : indexType === IndexType.UNSIGNED_SHORT + ? 2 + : 4; + return bytesPerIndex * indexCount; + } + + public static generateMeshId(): string { + return `mesh_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + public static validateIndexType(indexType: IndexType, vertexCount: number): void { + const maxIndex = + indexType === IndexType.UNSIGNED_BYTE + ? 255 + : indexType === IndexType.UNSIGNED_SHORT + ? 65535 + : 4294967295; + + if (vertexCount > maxIndex) { + throw new MeshError( + `Vertex count ${vertexCount} exceeds maximum for index type ${indexType} (${maxIndex})`, + MeshErrorCode.INVALID_INDEX_DATA + ); + } + } + + public static getOptimalIndexType(vertexCount: number): IndexType { + if (vertexCount <= 255) { + return IndexType.UNSIGNED_BYTE; + } else if (vertexCount <= 65535) { + return IndexType.UNSIGNED_SHORT; + } else { + return IndexType.UNSIGNED_INT; + } + } +} + +export class BoundingVolumeUtils { + + public static computeBoundingBox(positions: Float32Array): IBoundingBox { + if (positions.length === 0) { + const zero = new Vec3(0, 0, 0); + return { + min: zero as Vec3, + max: zero as Vec3, + center: zero as Vec3, + size: zero as Vec3, + radius: 0, + }; + } + + let minX = positions[0]; + let minY = positions[1]; + let minZ = positions[2]; + let maxX = positions[0]; + let maxY = positions[1]; + let maxZ = positions[2]; + + for (let i = 3; i < positions.length; i += 3) { + const x = positions[i]; + const y = positions[i + 1]; + const z = positions[i + 2]; + + minX = Math.min(minX, x); + minY = Math.min(minY, y); + minZ = Math.min(minZ, z); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + maxZ = Math.max(maxZ, z); + } + + const min = new Vec3(minX, minY, minZ); + const max = new Vec3(maxX, maxY, maxZ); + const center = Vec3.lerp(min, max, 0.5); + const size = Vec3.subtract(max, min); + const radius = Vec3.distance(center, max); + + return { + min: min as Vec3, + max: max as Vec3, + center: center as Vec3, + size: size as Vec3, + radius, + }; + } + + public static computeBoundingSphere(positions: Float32Array): IBoundingSphere { + const boundingBox = this.computeBoundingBox(positions); + return { + center: boundingBox.center, + radius: boundingBox.radius, + }; + } + + public static transformBoundingBox(boundingBox: IBoundingBox, transform: Mat4): IBoundingBox { + + const corners = [ + new Vec3(boundingBox.min.x, boundingBox.min.y, boundingBox.min.z), + new Vec3(boundingBox.max.x, boundingBox.min.y, boundingBox.min.z), + new Vec3(boundingBox.min.x, boundingBox.max.y, boundingBox.min.z), + new Vec3(boundingBox.max.x, boundingBox.max.y, boundingBox.min.z), + new Vec3(boundingBox.min.x, boundingBox.min.y, boundingBox.max.z), + new Vec3(boundingBox.max.x, boundingBox.min.y, boundingBox.max.z), + new Vec3(boundingBox.min.x, boundingBox.max.y, boundingBox.max.z), + new Vec3(boundingBox.max.x, boundingBox.max.y, boundingBox.max.z), + ]; + + const transformedCorners = corners.map((corner) => Mat4.transformVec3(corner, transform)); + + let minX = transformedCorners[0].x; + let minY = transformedCorners[0].y; + let minZ = transformedCorners[0].z; + let maxX = transformedCorners[0].x; + let maxY = transformedCorners[0].y; + let maxZ = transformedCorners[0].z; + + for (const corner of transformedCorners) { + minX = Math.min(minX, corner.x); + minY = Math.min(minY, corner.y); + minZ = Math.min(minZ, corner.z); + maxX = Math.max(maxX, corner.x); + maxY = Math.max(maxY, corner.y); + maxZ = Math.max(maxZ, corner.z); + } + + const min = new Vec3(minX, minY, minZ); + const max = new Vec3(maxX, maxY, maxZ); + const center = Vec3.lerp(min, max, 0.5); + const size = Vec3.subtract(max, min); + const radius = Vec3.distance(center, max); + + return { + min: new Vec3(min.x, min.y, min.z), + max: new Vec3(max.x, max.y, max.z), + center: new Vec3(center.x, center.y, center.z), + size: new Vec3(size.x, size.y, size.z), + radius, + }; + } + + public static transformBoundingSphere( + sphere: IBoundingSphere, + transform: Mat4 + ): IBoundingSphere { + const transformedCenter = Mat4.transformVec3(sphere.center, transform); + + const m = transform.data; + const scaleX = Math.sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]); + const scaleY = Math.sqrt(m[4] * m[4] + m[5] * m[5] + m[6] * m[6]); + const scaleZ = Math.sqrt(m[8] * m[8] + m[9] * m[9] + m[10] * m[10]); + const maxScale = Math.max(scaleX, scaleY, scaleZ); + + return { + center: transformedCenter as Vec3, + radius: sphere.radius * maxScale, + }; + } + + public static mergeBoundingBoxes(boxes: IBoundingBox[]): IBoundingBox { + if (boxes.length === 0) { + const zero = new Vec3(0, 0, 0); + return { + min: zero, + max: zero, + center: zero, + size: zero, + radius: 0, + }; + } + + let minX = boxes[0].min.x; + let minY = boxes[0].min.y; + let minZ = boxes[0].min.z; + let maxX = boxes[0].max.x; + let maxY = boxes[0].max.y; + let maxZ = boxes[0].max.z; + + for (const box of boxes) { + minX = Math.min(minX, box.min.x); + minY = Math.min(minY, box.min.y); + minZ = Math.min(minZ, box.min.z); + maxX = Math.max(maxX, box.max.x); + maxY = Math.max(maxY, box.max.y); + maxZ = Math.max(maxZ, box.max.z); + } + + const min = new Vec3(minX, minY, minZ); + const max = new Vec3(maxX, maxY, maxZ); + const center = Vec3.lerp(min, max, 0.5); + const size = Vec3.subtract(max, min); + const radius = Vec3.distance(center, max); + + return { + min: new Vec3(min.x, min.y, min.z), + max: new Vec3(max.x, max.y, max.z), + center: new Vec3(center.x, center.y, center.z), + size: new Vec3(size.x, size.y, size.z), + radius, + }; + } +} + +export class MeshGenerationUtils { + + public static generateSmoothNormals( + positions: Float32Array, + indices: Uint16Array | Uint32Array + ): Float32Array { + const vertexCount = positions.length / 3; + const normals = new Float32Array(positions.length); + + for (let i = 0; i < indices.length; i += 3) { + const i0 = indices[i] * 3; + const i1 = indices[i + 1] * 3; + const i2 = indices[i + 2] * 3; + + const v0 = new Vec3(positions[i0], positions[i0 + 1], positions[i0 + 2]); + const v1 = new Vec3(positions[i1], positions[i1 + 1], positions[i1 + 2]); + const v2 = new Vec3(positions[i2], positions[i2 + 1], positions[i2 + 2]); + + const edge1 = Vec3.subtract(v1, v0); + const edge2 = Vec3.subtract(v2, v0); + const faceNormal = Vec3.normalize(Vec3.cross(edge1, edge2)); + + normals[i0] += faceNormal.x; + normals[i0 + 1] += faceNormal.y; + normals[i0 + 2] += faceNormal.z; + + normals[i1] += faceNormal.x; + normals[i1 + 1] += faceNormal.y; + normals[i1 + 2] += faceNormal.z; + + normals[i2] += faceNormal.x; + normals[i2 + 1] += faceNormal.y; + normals[i2 + 2] += faceNormal.z; + } + + for (let i = 0; i < normals.length; i += 3) { + const normal = Vec3.normalize(new Vec3(normals[i], normals[i + 1], normals[i + 2])); + normals[i] = normal.x; + normals[i + 1] = normal.y; + normals[i + 2] = normal.z; + } + + return normals; + } + + public static generateTangents( + positions: Float32Array, + normals: Float32Array, + texCoords: Float32Array, + indices: Uint16Array | Uint32Array + ): Float32Array { + const vertexCount = positions.length / 3; + const tangents = new Float32Array(vertexCount * 4); + + const tan1 = new Float32Array(vertexCount * 3); + const tan2 = new Float32Array(vertexCount * 3); + + for (let i = 0; i < indices.length; i += 3) { + const i1 = indices[i]; + const i2 = indices[i + 1]; + const i3 = indices[i + 2]; + + const v1 = new Vec3(positions[i1 * 3], positions[i1 * 3 + 1], positions[i1 * 3 + 2]); + const v2 = new Vec3(positions[i2 * 3], positions[i2 * 3 + 1], positions[i2 * 3 + 2]); + const v3 = new Vec3(positions[i3 * 3], positions[i3 * 3 + 1], positions[i3 * 3 + 2]); + + const w1 = texCoords[i1 * 2]; + const w2 = texCoords[i2 * 2]; + const w3 = texCoords[i3 * 2]; + const h1 = texCoords[i1 * 2 + 1]; + const h2 = texCoords[i2 * 2 + 1]; + const h3 = texCoords[i3 * 2 + 1]; + + const x1 = v2.x - v1.x; + const x2 = v3.x - v1.x; + const y1 = v2.y - v1.y; + const y2 = v3.y - v1.y; + const z1 = v2.z - v1.z; + const z2 = v3.z - v1.z; + + const s1 = w2 - w1; + const s2 = w3 - w1; + const t1 = h2 - h1; + const t2 = h3 - h1; + + const r = 1.0 / (s1 * t2 - s2 * t1); + const sdir = new Vec3( + (t2 * x1 - t1 * x2) * r, + (t2 * y1 - t1 * y2) * r, + (t2 * z1 - t1 * z2) * r + ); + const tdir = new Vec3( + (s1 * x2 - s2 * x1) * r, + (s1 * y2 - s2 * y1) * r, + (s1 * z2 - s2 * z1) * r + ); + + tan1[i1 * 3] += sdir.x; + tan1[i1 * 3 + 1] += sdir.y; + tan1[i1 * 3 + 2] += sdir.z; + tan1[i2 * 3] += sdir.x; + tan1[i2 * 3 + 1] += sdir.y; + tan1[i2 * 3 + 2] += sdir.z; + tan1[i3 * 3] += sdir.x; + tan1[i3 * 3 + 1] += sdir.y; + tan1[i3 * 3 + 2] += sdir.z; + + tan2[i1 * 3] += tdir.x; + tan2[i1 * 3 + 1] += tdir.y; + tan2[i1 * 3 + 2] += tdir.z; + tan2[i2 * 3] += tdir.x; + tan2[i2 * 3 + 1] += tdir.y; + tan2[i2 * 3 + 2] += tdir.z; + tan2[i3 * 3] += tdir.x; + tan2[i3 * 3 + 1] += tdir.y; + tan2[i3 * 3 + 2] += tdir.z; + } + + for (let i = 0; i < vertexCount; i++) { + const n = new Vec3(normals[i * 3], normals[i * 3 + 1], normals[i * 3 + 2]); + const t = new Vec3(tan1[i * 3], tan1[i * 3 + 1], tan1[i * 3 + 2]); + + const dotProduct = Vec3.dot(n, t); + const scaled = Vec3.multiplyScalar(n, dotProduct); + const tangent = Vec3.normalize(Vec3.subtract(t, scaled)); + + const handedness = + Vec3.dot( + Vec3.cross(n, t), + new Vec3(tan2[i * 3], tan2[i * 3 + 1], tan2[i * 3 + 2]) + ) < 0.0 + ? -1.0 + : 1.0; + + tangents[i * 4] = tangent.x; + tangents[i * 4 + 1] = tangent.y; + tangents[i * 4 + 2] = tangent.z; + tangents[i * 4 + 3] = handedness; + } + + return tangents; + } +} diff --git a/packages/core/src/renderer/webgl2/mesh/vertex-buffer.ts b/packages/core/src/renderer/webgl2/mesh/vertex-buffer.ts new file mode 100644 index 0000000..4e84f73 --- /dev/null +++ b/packages/core/src/renderer/webgl2/mesh/vertex-buffer.ts @@ -0,0 +1,88 @@ +import { IBuffer, IBufferFactory, createBufferFactory, GLBufferUsage } from '../buffer'; +import { IVertexBuffer, IVertexLayout, BufferUsage, MeshError, MeshErrorCode } from './interfaces'; + +export class WebGLVertexBuffer implements IVertexBuffer { + public readonly id: string; + private readonly buffer: IBuffer; + private readonly bufferFactory: IBufferFactory; + public readonly usage: BufferUsage; + public readonly size: number; + public readonly vertexCount: number; + public readonly layout: IVertexLayout; + + private _isDisposed = false; + + constructor( + gl: WebGL2RenderingContext, + id: string, + data: ArrayBuffer | ArrayBufferView, + layout: IVertexLayout, + usage: BufferUsage = BufferUsage.STATIC_DRAW + ) { + this.id = id; + this.usage = usage; + this.layout = layout; + this.vertexCount = data.byteLength / layout.stride; + this.size = data.byteLength; + + this.bufferFactory = createBufferFactory(gl); + this.buffer = this.bufferFactory.createArrayBuffer({ + initialData: data as BufferSource, + usage: this.convertUsage(usage), + label: `VertexBuffer_${id}` + }); + } + + private convertUsage(usage: BufferUsage): GLBufferUsage { + switch (usage) { + case BufferUsage.STATIC_DRAW: return WebGL2RenderingContext.STATIC_DRAW; + case BufferUsage.DYNAMIC_DRAW: return WebGL2RenderingContext.DYNAMIC_DRAW; + case BufferUsage.STREAM_DRAW: return WebGL2RenderingContext.STREAM_DRAW; + default: return WebGL2RenderingContext.STATIC_DRAW; + } + } + + public get nativeHandle(): WebGLBuffer { + return this.buffer.id as unknown as WebGLBuffer; + } + + public get isDisposed(): boolean { + return this._isDisposed || this.buffer.isDisposed; + } + + public bind(): this { + if (this.isDisposed) { + throw new MeshError('Cannot bind disposed vertex buffer', MeshErrorCode.DISPOSED_RESOURCE_ACCESS); + } + this.buffer.bind(); + return this; + } + + public unbind(): this { + this.buffer.unbind(); + return this; + } + + public update(data: ArrayBuffer | ArrayBufferView, offset: number = 0): this { + if (this.isDisposed) { + throw new MeshError('Cannot update disposed vertex buffer', MeshErrorCode.DISPOSED_RESOURCE_ACCESS); + } + this.buffer.update(data as BufferSource, offset); + return this; + } + + public resize(newSize: number): this { + if (this.isDisposed) { + throw new MeshError('Cannot resize disposed vertex buffer', MeshErrorCode.DISPOSED_RESOURCE_ACCESS); + } + this.buffer.resize(newSize, this.convertUsage(this.usage)); + return this; + } + + public dispose(): void { + if (!this._isDisposed) { + this.buffer.dispose(); + this._isDisposed = true; + } + } +} From 93839de28d1663d5f8a43bce64613b8a7cf3a574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Thu, 23 Oct 2025 23:57:24 +0300 Subject: [PATCH 08/13] Add WebGL2 geometry and index buffer implementations --- .../core/src/renderer/webgl2/mesh/geometry.ts | 205 ++++++++++++++++++ .../src/renderer/webgl2/mesh/index-buffer.ts | 198 +++++++++++++++++ 2 files changed, 403 insertions(+) create mode 100644 packages/core/src/renderer/webgl2/mesh/geometry.ts create mode 100644 packages/core/src/renderer/webgl2/mesh/index-buffer.ts diff --git a/packages/core/src/renderer/webgl2/mesh/geometry.ts b/packages/core/src/renderer/webgl2/mesh/geometry.ts new file mode 100644 index 0000000..bc0233c --- /dev/null +++ b/packages/core/src/renderer/webgl2/mesh/geometry.ts @@ -0,0 +1,205 @@ +import { Vec3 } from '@axrone/numeric'; +import { IBuffer, IBufferFactory, createBufferFactory } from '../buffer'; +import { IGeometry, IVertexBuffer, IIndexBuffer, IVertexArrayObject, PrimitiveTopology, MeshError, MeshErrorCode, IBoundingBox, IBoundingSphere, VertexAttributeType, BufferUsage, IndexType } from './interfaces'; +import { IGeometryBuffers, IGeometryLayout } from '../../../geometry/primitives/types'; +import { WebGLVertexBuffer } from './vertex-buffer'; +import { WebGLIndexBuffer } from './index-buffer'; + +export class WebGLGeometry implements IGeometry { + public readonly id: string; + public readonly vertexBuffer: IVertexBuffer; + public readonly indexBuffer: IIndexBuffer | null; + public readonly vertexArrayObject: IVertexArrayObject; + public readonly primitiveTopology: PrimitiveTopology; + public readonly vertexCount: number; + public readonly indexCount: number; + public readonly boundingBox: IBoundingBox; + public readonly boundingSphere: IBoundingSphere; + public readonly vertexBuffers: readonly IVertexBuffer[]; + + private _isDisposed = false; + + constructor( + gl: WebGL2RenderingContext, + id: string, + geometryData: IGeometryBuffers, + topology: PrimitiveTopology = PrimitiveTopology.TRIANGLES + ) { + this.id = id; + this.primitiveTopology = topology; + this.vertexCount = geometryData.layout.vertexCount; + this.indexCount = geometryData.layout.indexCount; + + const vertexLayout = this.createVertexLayout(geometryData.layout); + this.vertexBuffer = new WebGLVertexBuffer( + gl, + `${id}_vertices`, + geometryData.vertices.toUint8Array(), + vertexLayout + ); + + if (geometryData.layout.indexCount > 0) { + const indexData = geometryData.indices.toUint8Array(); + this.indexBuffer = new WebGLIndexBuffer( + gl, + { + data: new Uint16Array(indexData.buffer), + usage: BufferUsage.STATIC_DRAW, + indexType: IndexType.UNSIGNED_SHORT + } + ); + } else { + this.indexBuffer = null; + } + + this.vertexBuffers = [this.vertexBuffer]; + + this.vertexArrayObject = this.createVAO(gl); + + this.boundingBox = this.computeInitialBounds(geometryData); + this.boundingSphere = this.computeInitialBoundingSphere(geometryData); + } + + private computeInitialBounds(geometryData: IGeometryBuffers): IBoundingBox { + + const min = new Vec3(-1, -1, -1); + const max = new Vec3(1, 1, 1); + const center = Vec3.lerp(min, max, 0.5); + const size = Vec3.subtract(max, min); + const radius = Vec3.distance(center, max); + + return { + min: new Vec3(min.x, min.y, min.z), + max: new Vec3(max.x, max.y, max.z), + center: new Vec3(center.x, center.y, center.z), + size: new Vec3(size.x, size.y, size.z), + radius + }; + } + + private computeInitialBoundingSphere(geometryData: IGeometryBuffers): IBoundingSphere { + const center = new Vec3(0, 0, 0); + const radius = 1.0; + + return { + center: new Vec3(center.x, center.y, center.z), + radius + }; + } + + private createVertexLayout(layout: IGeometryLayout): any { + + return { + attributes: layout.attributes.map(attr => ({ + type: attr.name as any, + dataType: attr.type as any, + componentCount: attr.size, + normalized: attr.normalized, + offset: attr.offset, + stride: layout.stride, + divisor: 0 + })), + stride: layout.stride, + vertexCount: layout.vertexCount + }; + } + + private createVAO(gl: WebGL2RenderingContext): IVertexArrayObject { + + const vao = gl.createVertexArray(); + if (!vao) { + throw new MeshError('Failed to create VAO', MeshErrorCode.VAO_CREATION_FAILED); + } + + const vaoId = `vao_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + return { + id: vaoId, + nativeHandle: vao, + vertexBuffers: this.vertexBuffers, + indexBuffer: this.indexBuffer, + isDisposed: false, + bind: () => { + gl.bindVertexArray(vao); + return this; + }, + unbind: () => { + gl.bindVertexArray(null); + return this; + }, + addVertexBuffer: (buffer: IVertexBuffer) => { + + console.warn('addVertexBuffer not fully implemented'); + }, + setIndexBuffer: (buffer: IIndexBuffer) => { + + console.warn('setIndexBuffer not fully implemented'); + }, + removeVertexBuffer: (buffer: IVertexBuffer): boolean => { + console.warn('removeVertexBuffer not fully implemented'); + return false; + }, + clearVertexBuffers: () => { + console.warn('clearVertexBuffers not fully implemented'); + }, + dispose: () => { + gl.deleteVertexArray(vao); + } + } as IVertexArrayObject; + } + + public get isDisposed(): boolean { + return this._isDisposed; + } + + public hasAttribute(type: VertexAttributeType): boolean { + + return this.vertexBuffer.layout.attributes.some(attr => attr.type === type); + } + + public getAttribute(type: VertexAttributeType): IVertexBuffer | null { + + return this.hasAttribute(type) ? this.vertexBuffer : null; + } + + public getAttributeData(type: VertexAttributeType): Float32Array | null { + + console.warn('getAttributeData not implemented'); + return null; + } + + public computeBounds(): void { + + console.warn('computeBounds not fully implemented'); + } + + public generateNormals(): void { + console.warn('generateNormals not implemented'); + } + + public generateTangents(): void { + console.warn('generateTangents not implemented'); + } + + public bind(): this { + if (this.isDisposed) { + throw new MeshError('Cannot bind disposed geometry', MeshErrorCode.DISPOSED_RESOURCE_ACCESS); + } + this.vertexArrayObject.bind(); + return this; + } + + public unbind(): this { + this.vertexArrayObject.unbind(); + return this; + } + + public dispose(): void { + if (!this._isDisposed) { + this.vertexBuffer.dispose(); + this.indexBuffer?.dispose(); + this.vertexArrayObject.dispose(); + this._isDisposed = true; + } + } +} diff --git a/packages/core/src/renderer/webgl2/mesh/index-buffer.ts b/packages/core/src/renderer/webgl2/mesh/index-buffer.ts new file mode 100644 index 0000000..aac4d36 --- /dev/null +++ b/packages/core/src/renderer/webgl2/mesh/index-buffer.ts @@ -0,0 +1,198 @@ +import { IBuffer, IBufferFactory, createBufferFactory, GLBufferUsage } from '../buffer'; +import { IIndexBuffer, IIndexBufferConfig, IndexBufferError, IndexType, BufferUsage } from './interfaces'; +import { ByteBuffer } from '@axrone/utility'; + +export class WebGLIndexBuffer implements IIndexBuffer { + private readonly gl: WebGL2RenderingContext; + private readonly bufferFactory: IBufferFactory; + private buffer: IBuffer | null = null; + private _count: number = 0; + private _indexType: IndexType = IndexType.UNSIGNED_SHORT; + private _usage: BufferUsage; + private _id: string; + + constructor(gl: WebGL2RenderingContext, config: IIndexBufferConfig) { + this.gl = gl; + this.bufferFactory = createBufferFactory(gl); + this._usage = config.usage || BufferUsage.STATIC_DRAW; + this._indexType = config.indexType || IndexType.UNSIGNED_SHORT; + this._id = `index_buffer_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + if (config.data) { + this.setData(config.data); + } + } + + get id(): string { + return this._id; + } + + get nativeHandle(): WebGLBuffer { + if (!this.buffer) { + throw new Error('IndexBuffer: Buffer not initialized'); + } + return this.buffer.id as WebGLBuffer; + } + + get count(): number { + return this._count; + } + + get indexCount(): number { + return this._count; + } + + get indexType(): IndexType { + return this._indexType; + } + + get usage(): BufferUsage { + return this._usage; + } + + get size(): number { + return this.buffer?.byteLength || 0; + } + + get byteLength(): number { + return this.buffer?.byteLength || 0; + } + + get isValid(): boolean { + return this.buffer !== null && this._count > 0; + } + + get isDisposed(): boolean { + return this.buffer === null; + } + + setData(data: ArrayBufferView | ArrayBuffer | ByteBuffer): IndexBufferError { + try { + + if (this.buffer) { + this.buffer.dispose(); + this.buffer = null; + } + + let bufferData: BufferSource; + + if (data instanceof ByteBuffer) { + const uint8Data = data.toUint8Array(); + bufferData = uint8Data as unknown as BufferSource; + this._indexType = IndexType.UNSIGNED_SHORT; + this._count = uint8Data.byteLength / 2; + } else if (data instanceof Uint16Array) { + this._indexType = IndexType.UNSIGNED_SHORT; + this._count = data.length; + bufferData = data as BufferSource; + } else if (data instanceof Uint32Array) { + this._indexType = IndexType.UNSIGNED_INT; + this._count = data.length; + bufferData = data as BufferSource; + } else if (data instanceof ArrayBuffer) { + + this._indexType = IndexType.UNSIGNED_SHORT; + this._count = data.byteLength / 2; + bufferData = data as BufferSource; + } else { + return IndexBufferError.INVALID_DATA_FORMAT; + } + + const glUsage = this.convertToGLUsage(this._usage); + this.buffer = this.bufferFactory.createElementArrayBufferFromData( + bufferData, + glUsage as GLBufferUsage + ); + + return IndexBufferError.NONE; + } catch (error) { + console.error('IndexBuffer: Failed to set data:', error); + return IndexBufferError.BUFFER_CREATION_FAILED; + } + } + + private convertToGLUsage(usage: BufferUsage): number { + switch (usage) { + case BufferUsage.STATIC_DRAW: return this.gl.STATIC_DRAW; + case BufferUsage.DYNAMIC_DRAW: return this.gl.DYNAMIC_DRAW; + case BufferUsage.STREAM_DRAW: return this.gl.STREAM_DRAW; + default: return this.gl.STATIC_DRAW; + } + } + + update(data: ArrayBuffer | ArrayBufferView, offset: number = 0): this { + if (data instanceof ArrayBuffer) { + + const view = new Uint8Array(data); + this.updateRange(view, offset); + } else { + this.updateRange(data, offset); + } + return this; + } + + resize(newSize: number): this { + + console.warn('IndexBuffer.resize not fully implemented'); + return this; + } + + updateRange(data: ArrayBufferView, offset: number = 0): IndexBufferError { + if (!this.buffer) { + return IndexBufferError.BUFFER_NOT_INITIALIZED; + } + + try { + this.buffer.updateRange(data as BufferSource, offset, 0); + return IndexBufferError.NONE; + } catch (error) { + console.error('IndexBuffer: Failed to update range:', error); + return IndexBufferError.UPDATE_FAILED; + } + } + + bind(): void { + if (this.buffer) { + this.buffer.bind(); + } + } + + unbind(): void { + if (this.buffer) { + this.buffer.unbind(); + } + } + + getGLIndexType(): number { + switch (this._indexType) { + case IndexType.UNSIGNED_SHORT: + return this.gl.UNSIGNED_SHORT; + case IndexType.UNSIGNED_INT: + return this.gl.UNSIGNED_INT; + case IndexType.UNSIGNED_BYTE: + return this.gl.UNSIGNED_BYTE; + default: + return this.gl.UNSIGNED_SHORT; + } + } + + drawElements(mode: number, count?: number, offset: number = 0): void { + if (!this.buffer) { + console.warn('IndexBuffer: Cannot draw - buffer not initialized'); + return; + } + + this.bind(); + const drawCount = count || this._count; + const indexType = this.getGLIndexType(); + this.gl.drawElements(mode, drawCount, indexType, offset); + } + + dispose(): void { + if (this.buffer) { + this.buffer.dispose(); + this.buffer = null; + } + this._count = 0; + } +} From 7dcc9c4fe5b4114ef3d7a4ec678257115df5e3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Fri, 24 Oct 2025 17:05:36 +0300 Subject: [PATCH 09/13] Add WebGL2 batch rendering system --- .../src/renderer/webgl2/batch/batch-group.ts | 229 +++++++++++++++ .../renderer/webgl2/batch/batch-manager.ts | 226 ++++++++++++++ .../renderer/webgl2/batch/batch-renderer.ts | 276 ++++++++++++++++++ .../core/src/renderer/webgl2/batch/index.ts | 8 + .../src/renderer/webgl2/batch/interfaces.ts | 58 ++++ 5 files changed, 797 insertions(+) create mode 100644 packages/core/src/renderer/webgl2/batch/batch-group.ts create mode 100644 packages/core/src/renderer/webgl2/batch/batch-manager.ts create mode 100644 packages/core/src/renderer/webgl2/batch/batch-renderer.ts create mode 100644 packages/core/src/renderer/webgl2/batch/index.ts create mode 100644 packages/core/src/renderer/webgl2/batch/interfaces.ts diff --git a/packages/core/src/renderer/webgl2/batch/batch-group.ts b/packages/core/src/renderer/webgl2/batch/batch-group.ts new file mode 100644 index 0000000..4d68b13 --- /dev/null +++ b/packages/core/src/renderer/webgl2/batch/batch-group.ts @@ -0,0 +1,229 @@ +import { Mat4 } from '@axrone/numeric'; +import { ObjectPool } from '@axrone/utility'; +import { IBatchable, IBatchGroup } from './interfaces'; +import { IMaterialInstance } from '../shader/interfaces'; +import { IBuffer, createBufferFactory } from '../buffer'; + +interface InstanceData { + worldMatrix: Float32Array; + color: Float32Array; + customData: Float32Array; +} + +export class BatchGroup implements IBatchGroup { + readonly id: string; + readonly material: IMaterialInstance; + readonly maxInstances: number; + readonly isDynamic: boolean; + + private readonly gl: WebGL2RenderingContext; + private readonly instancePool: ObjectPool; + private readonly instanceMap = new Map(); + private readonly matrixBuffer: IBuffer; + private readonly colorBuffer: IBuffer; + private readonly customBuffer: IBuffer; + + private instanceCount = 0; + private needsUpdate = true; + private disposed = false; + + constructor( + gl: WebGL2RenderingContext, + material: IMaterialInstance, + maxInstances: number = 1024, + isDynamic: boolean = false + ) { + this.gl = gl; + this.material = material; + this.maxInstances = maxInstances; + this.isDynamic = isDynamic; + this.id = `batch_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + + this.instancePool = new ObjectPool({ + factory: () => ({ + worldMatrix: new Float32Array(16), + color: new Float32Array(4), + customData: new Float32Array(4) + }), + resetHandler: (data) => { + data.worldMatrix.fill(0); + data.color.fill(0); + data.customData.fill(0); + } + }); + + const bufferFactory = createBufferFactory(gl); + + this.matrixBuffer = bufferFactory.createBuffer(gl.ARRAY_BUFFER, { + initialData: new Float32Array(maxInstances * 16), + usage: isDynamic ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW + }); + + this.colorBuffer = bufferFactory.createBuffer(gl.ARRAY_BUFFER, { + initialData: new Float32Array(maxInstances * 4), + usage: isDynamic ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW + }); + + this.customBuffer = bufferFactory.createBuffer(gl.ARRAY_BUFFER, { + initialData: new Float32Array(maxInstances * 4), + usage: isDynamic ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW + }); + } + + get instances(): readonly IBatchable[] { + return Array.from(this.instanceMap.values()); + } + + get isFull(): boolean { + return this.instanceCount >= this.maxInstances; + } + + get isEmpty(): boolean { + return this.instanceCount === 0; + } + + get size(): number { + return this.instanceCount; + } + + addInstance(instance: IBatchable): boolean { + if (this.disposed || this.isFull || this.instanceMap.has(instance.id)) { + return false; + } + + if (!this.isMaterialCompatible(instance.material)) { + return false; + } + + this.instanceMap.set(instance.id, instance); + this.instanceCount++; + this.needsUpdate = true; + + return true; + } + + removeInstance(instanceId: string): boolean { + if (this.disposed || !this.instanceMap.has(instanceId)) { + return false; + } + + this.instanceMap.delete(instanceId); + this.instanceCount--; + this.needsUpdate = true; + + return true; + } + + updateInstance(instanceId: string): void { + if (this.disposed || !this.instanceMap.has(instanceId)) { + return; + } + + this.needsUpdate = true; + } + + update(): void { + if (this.disposed || !this.needsUpdate || this.isEmpty) { + return; + } + + const matrixData = new Float32Array(this.instanceCount * 16); + const colorData = new Float32Array(this.instanceCount * 4); + const customData = new Float32Array(this.instanceCount * 4); + + let index = 0; + for (const instance of this.instanceMap.values()) { + if (!instance.visible) continue; + + matrixData.set(instance.worldMatrix.data, index * 16); + + const color = instance.material.getProperty('baseColor') as Float32Array || new Float32Array([1, 1, 1, 1]); + colorData.set(color, index * 4); + + const custom = instance.material.getProperty('customData') as Float32Array || new Float32Array([0, 0, 0, 0]); + customData.set(custom, index * 4); + + index++; + } + + this.matrixBuffer.bind(); + this.matrixBuffer.update(matrixData); + + this.colorBuffer.bind(); + this.colorBuffer.update(colorData); + + this.customBuffer.bind(); + this.customBuffer.update(customData); + + this.needsUpdate = false; + } + + render(viewMatrix: Mat4, projectionMatrix: Mat4): void { + if (this.disposed || this.isEmpty) { + return; + } + + this.update(); + + this.material.apply(); + + this.material.setProperty('viewMatrix', viewMatrix.data); + this.material.setProperty('projectionMatrix', projectionMatrix.data); + + this.setupInstanceAttributes(); + + this.gl.drawArraysInstanced( + this.gl.TRIANGLES, + 0, + 6, + this.instanceCount + ); + } + + dispose(): void { + if (this.disposed) return; + + this.matrixBuffer.dispose(); + this.colorBuffer.dispose(); + this.customBuffer.dispose(); + this.instanceMap.clear(); + + this.disposed = true; + } + + private isMaterialCompatible(material: IMaterialInstance): boolean { + + return this.material.shader === material.shader; + } + + private setupInstanceAttributes(): void { + const program = this.material.shader.shader.program; + + const matrixLocation = this.gl.getAttribLocation(program, 'instanceMatrix'); + if (matrixLocation !== -1) { + this.matrixBuffer.bind(); + for (let i = 0; i < 4; i++) { + const location = matrixLocation + i; + this.gl.enableVertexAttribArray(location); + this.gl.vertexAttribPointer(location, 4, this.gl.FLOAT, false, 64, i * 16); + this.gl.vertexAttribDivisor(location, 1); + } + } + + const colorLocation = this.gl.getAttribLocation(program, 'instanceColor'); + if (colorLocation !== -1) { + this.colorBuffer.bind(); + this.gl.enableVertexAttribArray(colorLocation); + this.gl.vertexAttribPointer(colorLocation, 4, this.gl.FLOAT, false, 0, 0); + this.gl.vertexAttribDivisor(colorLocation, 1); + } + + const customLocation = this.gl.getAttribLocation(program, 'instanceCustom'); + if (customLocation !== -1) { + this.customBuffer.bind(); + this.gl.enableVertexAttribArray(customLocation); + this.gl.vertexAttribPointer(customLocation, 4, this.gl.FLOAT, false, 0, 0); + this.gl.vertexAttribDivisor(customLocation, 1); + } + } +} diff --git a/packages/core/src/renderer/webgl2/batch/batch-manager.ts b/packages/core/src/renderer/webgl2/batch/batch-manager.ts new file mode 100644 index 0000000..a734b51 --- /dev/null +++ b/packages/core/src/renderer/webgl2/batch/batch-manager.ts @@ -0,0 +1,226 @@ +import { IBatchManager, IBatchRenderer, BatchStats, BatchConfiguration } from './interfaces'; +import { IMaterialInstance } from '../shader/interfaces'; +import { BatchRenderer } from './batch-renderer'; + +export class BatchManager implements IBatchManager { + private readonly gl: WebGL2RenderingContext; + private readonly config: Required; + private readonly renderers = new Map(); + private readonly materialRendererMap = new Map(); + + private disposed = false; + private frameCounter = 0; + + constructor(gl: WebGL2RenderingContext, config: BatchConfiguration = {}) { + this.gl = gl; + this.config = { + maxBatchSize: config.maxBatchSize ?? 1024, + maxRenderers: config.maxRenderers ?? 16, + enableDynamicBatching: config.enableDynamicBatching ?? true, + enableInstancing: config.enableInstancing ?? true, + sortByMaterial: config.sortByMaterial ?? true, + sortByDepth: config.sortByDepth ?? false + }; + } + + createRenderer(maxBatchSize?: number): IBatchRenderer { + if (this.disposed) { + throw new Error('BatchManager has been disposed'); + } + + if (this.renderers.size >= this.config.maxRenderers) { + throw new Error(`Maximum number of renderers (${this.config.maxRenderers}) reached`); + } + + const rendererId = `renderer_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + const renderer = new BatchRenderer(this.gl, { + ...this.config, + maxBatchSize: maxBatchSize ?? this.config.maxBatchSize + }); + + this.renderers.set(rendererId, renderer); + return renderer; + } + + getBestRenderer(material: IMaterialInstance): IBatchRenderer | null { + if (this.disposed) { + return null; + } + + const materialKey = this.getMaterialKey(material); + + const existingRendererId = this.materialRendererMap.get(materialKey); + if (existingRendererId && this.renderers.has(existingRendererId)) { + const renderer = this.renderers.get(existingRendererId)!; + if (renderer.activeGroups < this.config.maxRenderers) { + return renderer; + } + } + + let bestRenderer: BatchRenderer | null = null; + let minLoad = Infinity; + + for (const renderer of this.renderers.values()) { + const load = this.calculateRendererLoad(renderer); + if (load < minLoad) { + minLoad = load; + bestRenderer = renderer; + } + } + + if (!bestRenderer && this.renderers.size < this.config.maxRenderers) { + bestRenderer = this.createRenderer() as BatchRenderer; + } + + if (bestRenderer) { + const rendererId = this.getRendererKey(bestRenderer); + if (rendererId) { + this.materialRendererMap.set(materialKey, rendererId); + } + } + + return bestRenderer; + } + + optimizeBatches(): void { + if (this.disposed) { + return; + } + + this.frameCounter++; + + if (this.frameCounter % 60 === 0) { + this.performOptimization(); + } + } + + getStats(): BatchStats { + if (this.disposed) { + return { + totalRenderers: 0, + totalBatches: 0, + totalInstances: 0, + drawCalls: 0, + instancesPerBatch: 0, + memoryUsage: 0 + }; + } + + let totalBatches = 0; + let totalInstances = 0; + let totalDrawCalls = 0; + + for (const renderer of this.renderers.values()) { + totalBatches += renderer.activeGroups; + totalInstances += renderer.totalInstances; + + const frameStats = renderer.getFrameStats(); + totalDrawCalls += frameStats.drawCalls; + } + + return { + totalRenderers: this.renderers.size, + totalBatches, + totalInstances, + drawCalls: totalDrawCalls, + instancesPerBatch: totalBatches > 0 ? totalInstances / totalBatches : 0, + memoryUsage: this.calculateMemoryUsage() + }; + } + + dispose(): void { + if (this.disposed) return; + + for (const renderer of this.renderers.values()) { + renderer.dispose(); + } + + this.renderers.clear(); + this.materialRendererMap.clear(); + this.disposed = true; + } + + private performOptimization(): void { + + const emptyRenderers: string[] = []; + + for (const [id, renderer] of this.renderers) { + if (renderer.totalInstances === 0) { + emptyRenderers.push(id); + } + } + + for (const id of emptyRenderers) { + const renderer = this.renderers.get(id); + if (renderer) { + renderer.dispose(); + this.renderers.delete(id); + } + } + + for (const [materialKey, rendererId] of this.materialRendererMap) { + if (!this.renderers.has(rendererId)) { + this.materialRendererMap.delete(materialKey); + } + } + + this.rebalanceRenderers(); + } + + private rebalanceRenderers(): void { + + const rendererLoads = new Map(); + + for (const [id, renderer] of this.renderers) { + rendererLoads.set(id, this.calculateRendererLoad(renderer)); + } + + const avgLoad = Array.from(rendererLoads.values()).reduce((a, b) => a + b, 0) / rendererLoads.size; + const threshold = avgLoad * 1.5; + + for (const [id, load] of rendererLoads) { + if (load > threshold) { + + console.debug(`Renderer ${id} is overloaded (load: ${load}, threshold: ${threshold})`); + } + } + } + + private calculateRendererLoad(renderer: BatchRenderer): number { + + const groupWeight = 0.3; + const instanceWeight = 0.7; + + return (renderer.activeGroups * groupWeight) + (renderer.totalInstances * instanceWeight); + } + + private calculateMemoryUsage(): number { + + let totalMemory = 0; + + for (const renderer of this.renderers.values()) { + + const estimatedMemoryPerGroup = this.config.maxBatchSize * (16 + 4 + 4) * 4; + totalMemory += renderer.activeGroups * estimatedMemoryPerGroup; + } + + return totalMemory; + } + + private getMaterialKey(material: IMaterialInstance): string { + const shader = material.shader.shader.name; + const blendMode = material.getProperty('blendMode') as string || 'opaque'; + const cullMode = material.getProperty('cullMode') as string || 'back'; + + return `${shader}_${blendMode}_${cullMode}`; + } + + private getRendererKey(renderer: BatchRenderer): string | null { + for (const [id, r] of this.renderers) { + if (r === renderer) { + return id; + } + } + return null; + } +} diff --git a/packages/core/src/renderer/webgl2/batch/batch-renderer.ts b/packages/core/src/renderer/webgl2/batch/batch-renderer.ts new file mode 100644 index 0000000..50b6a8c --- /dev/null +++ b/packages/core/src/renderer/webgl2/batch/batch-renderer.ts @@ -0,0 +1,276 @@ +import { Mat4 } from '@axrone/numeric'; +import { PriorityQueue } from '@axrone/utility'; +import { IBatchable, IBatchRenderer, BatchConfiguration } from './interfaces'; +import { IMaterialInstance } from '../shader/interfaces'; +import { BatchGroup } from './batch-group'; + +interface BatchJob { + group: BatchGroup; + priority: number; + depth: number; +} + +export class BatchRenderer implements IBatchRenderer { + readonly maxBatchSize: number; + + private readonly gl: WebGL2RenderingContext; + private readonly config: Required; + private readonly batchGroups = new Map(); + private readonly materialGroups = new Map(); + private readonly renderQueue = new PriorityQueue(); + + private disposed = false; + private frameStats = { + drawCalls: 0, + instancesRendered: 0, + batchesProcessed: 0 + }; + + constructor(gl: WebGL2RenderingContext, config: BatchConfiguration = {}) { + this.gl = gl; + this.maxBatchSize = config.maxBatchSize ?? 1024; + + this.config = { + maxBatchSize: config.maxBatchSize ?? 1024, + maxRenderers: config.maxRenderers ?? 16, + enableDynamicBatching: config.enableDynamicBatching ?? true, + enableInstancing: config.enableInstancing ?? true, + sortByMaterial: config.sortByMaterial ?? true, + sortByDepth: config.sortByDepth ?? false + }; + } + + get activeGroups(): number { + return this.batchGroups.size; + } + + get totalInstances(): number { + let total = 0; + for (const group of this.batchGroups.values()) { + total += group.size; + } + return total; + } + + addInstance(instance: IBatchable): boolean { + if (this.disposed || !instance.visible) { + return false; + } + + const materialKey = this.getMaterialKey(instance.material); + let targetGroup = this.findCompatibleGroup(materialKey, instance); + + if (!targetGroup) { + targetGroup = this.createBatchGroup(instance.material); + if (!targetGroup) { + return false; + } + } + + return targetGroup.addInstance(instance); + } + + removeInstance(instanceId: string): boolean { + if (this.disposed) { + return false; + } + + for (const group of this.batchGroups.values()) { + if (group.removeInstance(instanceId)) { + + if (group.isEmpty) { + this.removeBatchGroup(group); + } + return true; + } + } + + return false; + } + + updateInstance(instanceId: string): void { + if (this.disposed) { + return; + } + + for (const group of this.batchGroups.values()) { + const instance = group.instances.find(inst => inst.id === instanceId); + if (instance) { + group.updateInstance(instanceId); + break; + } + } + } + + render(viewMatrix: Mat4, projectionMatrix: Mat4): void { + if (this.disposed) { + return; + } + + this.frameStats = { drawCalls: 0, instancesRendered: 0, batchesProcessed: 0 }; + + this.buildRenderQueue(viewMatrix); + + while (!this.renderQueue.isEmpty) { + const job = this.renderQueue.tryDequeue(); + if (!job) break; + + job.group.render(viewMatrix, projectionMatrix); + + this.frameStats.drawCalls++; + this.frameStats.instancesRendered += job.group.size; + this.frameStats.batchesProcessed++; + } + } + + flush(): void { + if (this.disposed) { + return; + } + + for (const group of this.batchGroups.values()) { + group.update(); + } + } + + dispose(): void { + if (this.disposed) return; + + for (const group of this.batchGroups.values()) { + group.dispose(); + } + + this.batchGroups.clear(); + this.materialGroups.clear(); + this.renderQueue.clear(); + + this.disposed = true; + } + + getFrameStats() { + return { ...this.frameStats }; + } + + private findCompatibleGroup(materialKey: string, instance: IBatchable): BatchGroup | null { + const groups = this.materialGroups.get(materialKey); + if (!groups) { + return null; + } + + for (const group of groups) { + if (!group.isFull && this.isInstanceCompatible(group, instance)) { + return group; + } + } + + return null; + } + + private createBatchGroup(material: IMaterialInstance): BatchGroup | null { + if (this.batchGroups.size >= this.config.maxRenderers) { + return null; + } + + const group = new BatchGroup( + this.gl, + material, + this.maxBatchSize, + this.config.enableDynamicBatching + ); + + this.batchGroups.set(group.id, group); + + const materialKey = this.getMaterialKey(material); + if (!this.materialGroups.has(materialKey)) { + this.materialGroups.set(materialKey, []); + } + this.materialGroups.get(materialKey)!.push(group); + + return group; + } + + private removeBatchGroup(group: BatchGroup): void { + const materialKey = this.getMaterialKey(group.material); + const groups = this.materialGroups.get(materialKey); + + if (groups) { + const index = groups.indexOf(group); + if (index !== -1) { + groups.splice(index, 1); + } + + if (groups.length === 0) { + this.materialGroups.delete(materialKey); + } + } + + this.batchGroups.delete(group.id); + group.dispose(); + } + + private buildRenderQueue(viewMatrix: Mat4): void { + this.renderQueue.clear(); + + for (const group of this.batchGroups.values()) { + if (group.isEmpty) continue; + + const priority = this.calculateGroupPriority(group); + const depth = this.calculateGroupDepth(group, viewMatrix); + + this.renderQueue.enqueue({ + group, + priority, + depth + }, priority); + } + } + + private calculateGroupPriority(group: BatchGroup): number { + + const material = group.material; + const blendMode = material.getProperty('blendMode') as string; + + if (blendMode === 'opaque') { + return 1000; + } else if (blendMode === 'alpha_blend') { + return 500; + } else { + return 100; + } + } + + private calculateGroupDepth(group: BatchGroup, viewMatrix: Mat4): number { + if (!this.config.sortByDepth || group.isEmpty) { + return 0; + } + + let totalDepth = 0; + let count = 0; + + for (const instance of group.instances) { + if (instance.visible) { + + const worldPos = instance.worldMatrix.data.slice(12, 15); + const viewPos = viewMatrix.multiply(instance.worldMatrix).data.slice(12, 15); + totalDepth += viewPos[2]; + count++; + } + } + + return count > 0 ? totalDepth / count : 0; + } + + private getMaterialKey(material: IMaterialInstance): string { + + const shader = material.shader.shader.name; + const blendMode = material.getProperty('blendMode') as string || 'opaque'; + const cullMode = material.getProperty('cullMode') as string || 'back'; + + return `${shader}_${blendMode}_${cullMode}`; + } + + private isInstanceCompatible(group: BatchGroup, instance: IBatchable): boolean { + + return group.material.shader === instance.material.shader; + } +} diff --git a/packages/core/src/renderer/webgl2/batch/index.ts b/packages/core/src/renderer/webgl2/batch/index.ts new file mode 100644 index 0000000..285bea7 --- /dev/null +++ b/packages/core/src/renderer/webgl2/batch/index.ts @@ -0,0 +1,8 @@ +export * from './interfaces'; +export * from './batch-group'; +export * from './batch-renderer'; +export * from './batch-manager'; + +export { BatchGroup } from './batch-group'; +export { BatchRenderer } from './batch-renderer'; +export { BatchManager } from './batch-manager'; \ No newline at end of file diff --git a/packages/core/src/renderer/webgl2/batch/interfaces.ts b/packages/core/src/renderer/webgl2/batch/interfaces.ts new file mode 100644 index 0000000..2792637 --- /dev/null +++ b/packages/core/src/renderer/webgl2/batch/interfaces.ts @@ -0,0 +1,58 @@ +import { Mat4 } from '@axrone/numeric'; +import { IMaterialInstance } from '../shader/interfaces'; + +export interface IBatchable { + readonly id: string; + readonly worldMatrix: Mat4; + readonly material: IMaterialInstance; + readonly visible: boolean; + readonly castShadows: boolean; + readonly receiveShadows: boolean; +} + +export interface IBatchGroup { + readonly id: string; + readonly material: IMaterialInstance; + readonly instances: readonly IBatchable[]; + readonly maxInstances: number; + readonly isDynamic: boolean; +} + +export interface IBatchRenderer { + readonly maxBatchSize: number; + readonly activeGroups: number; + readonly totalInstances: number; + + addInstance(instance: IBatchable): boolean; + removeInstance(instanceId: string): boolean; + updateInstance(instanceId: string): void; + render(viewMatrix: Mat4, projectionMatrix: Mat4): void; + flush(): void; + dispose(): void; +} + +export interface IBatchManager { + createRenderer(maxBatchSize?: number): IBatchRenderer; + getBestRenderer(material: IMaterialInstance): IBatchRenderer | null; + optimizeBatches(): void; + getStats(): BatchStats; + dispose(): void; +} + +export interface BatchStats { + readonly totalRenderers: number; + readonly totalBatches: number; + readonly totalInstances: number; + readonly drawCalls: number; + readonly instancesPerBatch: number; + readonly memoryUsage: number; +} + +export interface BatchConfiguration { + readonly maxBatchSize?: number; + readonly maxRenderers?: number; + readonly enableDynamicBatching?: boolean; + readonly enableInstancing?: boolean; + readonly sortByMaterial?: boolean; + readonly sortByDepth?: boolean; +} \ No newline at end of file From 0f6180104a9e84ceed73e568ed652668e5bd5795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Fri, 24 Oct 2025 17:16:40 +0300 Subject: [PATCH 10/13] Remove ShaderCache class from WebGL2 renderer --- .../renderer/webgl2/shader/shader-cache.ts | 58 ------------------- 1 file changed, 58 deletions(-) delete mode 100644 packages/core/src/renderer/webgl2/shader/shader-cache.ts diff --git a/packages/core/src/renderer/webgl2/shader/shader-cache.ts b/packages/core/src/renderer/webgl2/shader/shader-cache.ts deleted file mode 100644 index 91c2ad9..0000000 --- a/packages/core/src/renderer/webgl2/shader/shader-cache.ts +++ /dev/null @@ -1,58 +0,0 @@ -export class ShaderCache { - private readonly programs = new Map(); - private readonly gl: WebGL2RenderingContext; - - constructor(gl: WebGL2RenderingContext) { - this.gl = gl; - } - - getOrCreate( - vertexSource: string, - fragmentSource: string, - defines: Record = {}, - key = this.generateKey(vertexSource, fragmentSource, defines) - ): ShaderProgram { - if (this.programs.has(key)) { - return this.programs.get(key)!; - } - - const program = createShaderProgram(this.gl, vertexSource, fragmentSource, { defines }); - this.programs.set(key, program); - - return program; - } - - private generateKey(vertexSource: string, fragmentSource: string, defines: Record): string { - const definesKey = Object.entries(defines) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, value]) => `${key}:${value}`) - .join('|'); - - const vsHash = this.hashString(vertexSource); - const fsHash = this.hashString(fragmentSource); - const defHash = this.hashString(definesKey); - - return `${vsHash}_${fsHash}_${defHash}`; - } - - private hashString(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - return Math.abs(hash).toString(36); - } - - clear(): void { - for (const program of this.programs.values()) { - program.dispose(); - } - this.programs.clear(); - } - - dispose(): void { - this.clear(); - } -} \ No newline at end of file From 2d162a01734ddfb2c36c39607d9cd2af7eeb1c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Fri, 24 Oct 2025 17:20:26 +0300 Subject: [PATCH 11/13] Add WebGL2 shader system core implementation --- packages/core/src/renderer/webgl2/index.ts | 22 + .../src/renderer/webgl2/shader/compiler.ts | 494 ++++++++++++++++++ .../webgl2/shader/examples/usage-examples.ts | 353 +++++++++++++ .../core/src/renderer/webgl2/shader/index.ts | 35 ++ .../src/renderer/webgl2/shader/instance.ts | 418 +++++++++++++++ .../src/renderer/webgl2/shader/interfaces.ts | 339 ++++++++++++ .../src/renderer/webgl2/shader/manager.ts | 366 +++++++++++++ .../src/renderer/webgl2/shader/material.ts | 341 ++++++++++++ .../shader/shader-compilation-worker.ts | 105 ++++ .../shader/templates/standard-shaders.ts | 123 +++++ .../core/src/renderer/webgl2/shader/utils.ts | 369 +++++++++++++ 11 files changed, 2965 insertions(+) create mode 100644 packages/core/src/renderer/webgl2/index.ts create mode 100644 packages/core/src/renderer/webgl2/shader/compiler.ts create mode 100644 packages/core/src/renderer/webgl2/shader/examples/usage-examples.ts create mode 100644 packages/core/src/renderer/webgl2/shader/index.ts create mode 100644 packages/core/src/renderer/webgl2/shader/instance.ts create mode 100644 packages/core/src/renderer/webgl2/shader/interfaces.ts create mode 100644 packages/core/src/renderer/webgl2/shader/manager.ts create mode 100644 packages/core/src/renderer/webgl2/shader/material.ts create mode 100644 packages/core/src/renderer/webgl2/shader/shader-compilation-worker.ts create mode 100644 packages/core/src/renderer/webgl2/shader/templates/standard-shaders.ts create mode 100644 packages/core/src/renderer/webgl2/shader/utils.ts diff --git a/packages/core/src/renderer/webgl2/index.ts b/packages/core/src/renderer/webgl2/index.ts new file mode 100644 index 0000000..ff7ba4d --- /dev/null +++ b/packages/core/src/renderer/webgl2/index.ts @@ -0,0 +1,22 @@ +// Core WebGL2 Components +export * from './buffer'; +export * from './framebuffer'; +export * from './vao'; + +// Shader System +export * from './shader'; + +// Material System +export * from './material'; + +// Mesh System +export * from './mesh'; + +// Texture System +export * from './texture'; + +// Batch Rendering System +export * from './batch'; + +// Rendering Pipeline +export * from './rendering'; \ No newline at end of file diff --git a/packages/core/src/renderer/webgl2/shader/compiler.ts b/packages/core/src/renderer/webgl2/shader/compiler.ts new file mode 100644 index 0000000..59e6e7d --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/compiler.ts @@ -0,0 +1,494 @@ +import { + IShaderConfiguration, + ICompiledShader, + IShaderVariant, + IShaderCompiler, + IShaderCompilerOptions, + ValidationResult, + ShaderDataType, + ShaderStage, + IVertexAttribute, + IUniformVariable, + IUniformBlock, + ITextureProperty +} from './interfaces'; + +import { + generateVersionDirective, + generatePrecisionDirective, + generateDefines, + generateIncludes, + hashShaderSource, + generateVariantKey, + isValidShaderVariableName, + isValidStageCombo, + validateUniformNaming, + calculateUniformBufferLayout, + getShaderDataTypeSize, + VERTEX_SEMANTICS, + UNIFORM_SEMANTICS, + SHADER_KEYWORDS +} from './utils'; + +import { ByteBuffer } from '@axrone/utility'; + +class ShaderSourceGenerator { + private readonly includeCache = new Map(); + + generateShaderSource( + config: IShaderConfiguration, + stage: ShaderStage, + keywords: string[] = [], + defines: Record = {} + ): string { + const source: string[] = []; + + source.push(generateVersionDirective('300 es')); + + if (stage === ShaderStage.FRAGMENT) { + source.push(generatePrecisionDirective('highp')); + } + + const allDefines = { ...config.defines, ...defines }; + source.push(generateDefines(allDefines)); + + keywords.forEach(keyword => { + source.push(`#define ${keyword}\n`); + }); + + if (config.includes) { + source.push(generateIncludes(config.includes)); + } + + source.push(this.generateVariableDeclarations(config, stage)); + + const stageSource = this.getStageSource(config, stage); + if (stageSource) { + source.push('\n// Stage-specific code\n'); + source.push(stageSource); + } + + return source.join(''); + } + + private generateVariableDeclarations(config: IShaderConfiguration, stage: ShaderStage): string { + const declarations: string[] = []; + + if (stage === ShaderStage.VERTEX) { + config.attributes.forEach(attr => { + const precision = attr.precision ? `${attr.precision} ` : ''; + declarations.push(`layout(location = ${attr.binding}) in ${precision}${attr.type} ${attr.name};\n`); + }); + } + + config.uniforms.forEach(uniform => { + const precision = uniform.precision ? `${uniform.precision} ` : ''; + const arraySpec = uniform.arraySize ? `[${uniform.arraySize}]` : ''; + declarations.push(`uniform ${precision}${uniform.type} ${uniform.name}${arraySpec};\n`); + }); + + if (config.uniformBlocks) { + config.uniformBlocks.forEach(block => { + declarations.push(this.generateUniformBlock(block)); + }); + } + + config.textures.forEach(texture => { + declarations.push(`uniform ${texture.type} ${texture.name};\n`); + }); + + if (config.varyings) { + config.varyings.forEach(varying => { + const precision = varying.precision ? `${varying.precision} ` : ''; + const interpolation = varying.interpolation ? `${varying.interpolation} ` : ''; + + if (stage === ShaderStage.VERTEX) { + declarations.push(`${interpolation}out ${precision}${varying.type} ${varying.name};\n`); + } else if (stage === ShaderStage.FRAGMENT) { + declarations.push(`${interpolation}in ${precision}${varying.type} ${varying.name};\n`); + } + }); + } + + return declarations.join(''); + } + + private generateUniformBlock(block: IUniformBlock): string { + const layout = `layout(std140, binding = ${block.binding})`; + const variables = block.variables.map(variable => { + const precision = variable.precision ? `${variable.precision} ` : ''; + const arraySpec = variable.arraySize ? `[${variable.arraySize}]` : ''; + return ` ${precision}${variable.type} ${variable.name}${arraySpec};`; + }).join('\n'); + + return `${layout} uniform ${block.name} {\n${variables}\n};\n`; + } + + private getStageSource(config: IShaderConfiguration, stage: ShaderStage): string { + const pass = config.passes[0]; + + switch (stage) { + case ShaderStage.VERTEX: + return pass.vertexShader; + case ShaderStage.FRAGMENT: + return pass.fragmentShader || ''; + case ShaderStage.GEOMETRY: + return pass.geometryShader || ''; + case ShaderStage.TESSELLATION_CONTROL: + return pass.tessellationControlShader || ''; + case ShaderStage.TESSELLATION_EVALUATION: + return pass.tessellationEvaluationShader || ''; + case ShaderStage.COMPUTE: + return pass.computeShader || ''; + default: + return ''; + } + } +} + +export class WebGLShaderCompiler implements IShaderCompiler { + private readonly gl: WebGL2RenderingContext; + private readonly sourceGenerator: ShaderSourceGenerator; + private readonly compilationCache = new Map(); + private readonly variantCache = new Map(); + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.sourceGenerator = new ShaderSourceGenerator(); + } + + async compile( + configuration: IShaderConfiguration, + options: IShaderCompilerOptions = {} + ): Promise { + const startTime = performance.now(); + + const validation = this.validateConfiguration(configuration); + if (!validation.isValid) { + throw new Error(`Shader validation failed: ${validation.errors.join(', ')}`); + } + + const cacheKey = this.generateConfigurationKey(configuration); + + if (this.compilationCache.has(cacheKey)) { + return this.compilationCache.get(cacheKey)!; + } + + const program = await this.compileShaderProgram(configuration, [], {}); + + const reflection = this.extractReflectionData(program, configuration); + + const compiledShader: ICompiledShader = { + id: cacheKey, + name: configuration.name, + configuration, + program, + uniformLocations: reflection.uniformLocations, + attributeLocations: reflection.attributeLocations, + uniformBlocks: reflection.uniformBlocks, + textureSlots: reflection.textureSlots, + renderState: configuration.passes[0].renderState, + bytecodeSize: this.calculateBytecodeSize(program), + compilationTime: performance.now() - startTime + }; + + this.compilationCache.set(cacheKey, compiledShader); + + return compiledShader; + } + + async compileVariant( + shader: ICompiledShader, + keywords: string[], + defines: Record + ): Promise { + const variantKey = generateVariantKey(shader.name, keywords, defines); + + if (this.variantCache.has(variantKey)) { + return this.variantCache.get(variantKey)!; + } + + const program = await this.compileShaderProgram(shader.configuration, keywords, defines); + + const reflection = this.extractReflectionData(program, shader.configuration); + + const variantShader: ICompiledShader = { + ...shader, + id: variantKey, + program, + uniformLocations: reflection.uniformLocations, + attributeLocations: reflection.attributeLocations, + uniformBlocks: reflection.uniformBlocks, + textureSlots: reflection.textureSlots, + }; + + const variant: IShaderVariant = { + keywords: Object.freeze([...keywords]), + defines: Object.freeze({ ...defines }), + hash: variantKey, + shader: variantShader + }; + + this.variantCache.set(variantKey, variant); + + return variant; + } + + validateConfiguration(configuration: IShaderConfiguration): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!configuration.name || typeof configuration.name !== 'string') { + errors.push('Shader name is required and must be a string'); + } + + if (!configuration.passes || configuration.passes.length === 0) { + errors.push('Shader must have at least one pass'); + } + + configuration.passes.forEach((pass, index) => { + if (!isValidStageCombo(pass.stage)) { + errors.push(`Pass ${index}: Invalid shader stage combination`); + } + + if (!pass.vertexShader && pass.stage.includes(ShaderStage.VERTEX)) { + errors.push(`Pass ${index}: Vertex shader source is required`); + } + + if (!pass.fragmentShader && pass.stage.includes(ShaderStage.FRAGMENT)) { + errors.push(`Pass ${index}: Fragment shader source is required`); + } + }); + + const attributeBindings = new Set(); + configuration.attributes.forEach((attr, index) => { + if (!isValidShaderVariableName(attr.name)) { + errors.push(`Attribute ${index}: Invalid variable name "${attr.name}"`); + } + + if (attributeBindings.has(attr.binding)) { + errors.push(`Attribute ${index}: Binding ${attr.binding} is already used`); + } + attributeBindings.add(attr.binding); + }); + + const uniformNames = new Set(); + configuration.uniforms.forEach((uniform, index) => { + const validation = validateUniformNaming(uniform.name); + if (!validation.valid) { + errors.push(`Uniform ${index}: ${validation.warnings.join(', ')}`); + } + warnings.push(...validation.warnings); + + if (uniformNames.has(uniform.name)) { + errors.push(`Uniform ${index}: Name "${uniform.name}" is already used`); + } + uniformNames.add(uniform.name); + }); + + const textureSlots = new Set(); + configuration.textures.forEach((texture, index) => { + if (!isValidShaderVariableName(texture.name)) { + errors.push(`Texture ${index}: Invalid variable name "${texture.name}"`); + } + + if (textureSlots.has(texture.slot)) { + errors.push(`Texture ${index}: Slot ${texture.slot} is already used`); + } + textureSlots.add(texture.slot); + }); + + return { + isValid: errors.length === 0, + errors, + warnings + }; + } + + private async compileShaderProgram( + configuration: IShaderConfiguration, + keywords: string[], + defines: Record + ): Promise { + const pass = configuration.passes[0]; + const program = this.gl.createProgram(); + + if (!program) { + throw new Error('Failed to create WebGL program'); + } + + try { + + if (pass.stage.includes(ShaderStage.VERTEX)) { + const vertexSource = this.sourceGenerator.generateShaderSource( + configuration, + ShaderStage.VERTEX, + keywords, + defines + ); + const vertexShader = this.compileShader(this.gl.VERTEX_SHADER, vertexSource); + this.gl.attachShader(program, vertexShader); + } + + if (pass.stage.includes(ShaderStage.FRAGMENT)) { + const fragmentSource = this.sourceGenerator.generateShaderSource( + configuration, + ShaderStage.FRAGMENT, + keywords, + defines + ); + const fragmentShader = this.compileShader(this.gl.FRAGMENT_SHADER, fragmentSource); + this.gl.attachShader(program, fragmentShader); + } + + this.gl.linkProgram(program); + + if (!this.gl.getProgramParameter(program, this.gl.LINK_STATUS)) { + const info = this.gl.getProgramInfoLog(program); + throw new Error(`Shader program linking failed: ${info}`); + } + + return program; + } catch (error) { + this.gl.deleteProgram(program); + throw error; + } + } + + private compileShader(type: number, source: string): WebGLShader { + const shader = this.gl.createShader(type); + if (!shader) { + throw new Error('Failed to create WebGL shader'); + } + + this.gl.shaderSource(shader, source); + this.gl.compileShader(shader); + + if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { + const info = this.gl.getShaderInfoLog(shader); + this.gl.deleteShader(shader); + throw new Error(`Shader compilation failed: ${info}`); + } + + return shader; + } + + private extractReflectionData(program: WebGLProgram, configuration: IShaderConfiguration) { + const uniformLocations = new Map(); + const attributeLocations = new Map(); + const uniformBlocks = new Map(); + const textureSlots = new Map(); + + configuration.uniforms.forEach(uniform => { + const location = this.gl.getUniformLocation(program, uniform.name); + if (location) { + uniformLocations.set(uniform.name, location); + } + }); + + configuration.attributes.forEach(attribute => { + const location = this.gl.getAttribLocation(program, attribute.name); + if (location !== -1) { + attributeLocations.set(attribute.name, location); + } + }); + + if (configuration.uniformBlocks) { + configuration.uniformBlocks.forEach(block => { + const index = this.gl.getUniformBlockIndex(program, block.name); + if (index !== this.gl.INVALID_INDEX) { + + const layout = calculateUniformBufferLayout( + block.variables.map(v => ({ + name: v.name, + type: v.type, + arraySize: v.arraySize + })) + ); + + const buffer = ByteBuffer.alloc(layout.totalSize); + + uniformBlocks.set(block.name, { + ...block, + size: layout.totalSize, + buffer + }); + } + }); + } + + configuration.textures.forEach(texture => { + textureSlots.set(texture.name, texture.slot); + }); + + return { + uniformLocations, + attributeLocations, + uniformBlocks, + textureSlots + }; + } + + private calculateBytecodeSize(program: WebGLProgram): number { + + const attachedShaders = this.gl.getAttachedShaders(program) || []; + return attachedShaders.reduce((size: number, shader: WebGLShader) => { + const source = this.gl.getShaderSource(shader) || ''; + return size + source.length; + }, 0); + } + + private generateConfigurationKey(configuration: IShaderConfiguration): string { + const configString = JSON.stringify({ + name: configuration.name, + version: configuration.version, + passes: configuration.passes.map(pass => ({ + stage: pass.stage, + vertexShader: hashShaderSource(pass.vertexShader), + fragmentShader: pass.fragmentShader ? hashShaderSource(pass.fragmentShader) : null, + renderState: pass.renderState + })), + attributes: configuration.attributes, + uniforms: configuration.uniforms, + textures: configuration.textures + }); + + return hashShaderSource(configString); + } + + clearCache(): void { + + for (const shader of this.compilationCache.values()) { + this.gl.deleteProgram(shader.program); + } + + for (const variant of this.variantCache.values()) { + this.gl.deleteProgram(variant.shader.program); + } + + this.compilationCache.clear(); + this.variantCache.clear(); + } + + getCacheStats() { + return { + compiledShaders: this.compilationCache.size, + variants: this.variantCache.size, + memoryUsage: this.calculateCacheMemoryUsage() + }; + } + + private calculateCacheMemoryUsage(): number { + let totalSize = 0; + + for (const shader of this.compilationCache.values()) { + totalSize += shader.bytecodeSize; + } + + for (const variant of this.variantCache.values()) { + totalSize += variant.shader.bytecodeSize; + } + + return totalSize; + } +} diff --git a/packages/core/src/renderer/webgl2/shader/examples/usage-examples.ts b/packages/core/src/renderer/webgl2/shader/examples/usage-examples.ts new file mode 100644 index 0000000..d74c919 --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/examples/usage-examples.ts @@ -0,0 +1,353 @@ +import { + ShaderManager, + WebGLShaderCompiler, + MaterialInstance, + IShaderConfiguration, + ShaderDataType, + ShaderQualifier, + ShaderStage, + BlendMode, + CullMode, + DepthFunc +} from '../index'; + +import { StandardUnlitShader } from '../templates/standard-shaders'; + +async function basicShaderUsage() { + + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2'); + if (!gl) { + throw new Error('WebGL2 not supported'); + } + + const shaderManager = new ShaderManager(gl); + + const shader = await shaderManager.loadFromConfiguration(StandardUnlitShader); + + const material = shaderManager.createMaterial('Standard/Unlit', { + u_Color: [1.0, 0.5, 0.0, 1.0], + }); + + material.enableKeyword('MAIN_TEXTURE'); + + console.log('Material created successfully!'); + return { shader, material, shaderManager }; +} + +const CustomEffectShader: IShaderConfiguration = { + name: "Custom/Effect", + version: "1.0.0", + description: "Custom effect shader with time-based animation", + author: "Your Name", + tags: ["custom", "effect", "animated"], + category: "Effects", + + attributes: [ + { + name: "a_Position", + type: ShaderDataType.VEC3, + qualifier: ShaderQualifier.ATTRIBUTE, + binding: 0, + semantic: "POSITION" + }, + { + name: "a_TexCoord", + type: ShaderDataType.VEC2, + qualifier: ShaderQualifier.ATTRIBUTE, + binding: 1, + semantic: "TEXCOORD" + } + ], + + uniforms: [ + { + name: "u_MVPMatrix", + type: ShaderDataType.MAT4, + qualifier: ShaderQualifier.UNIFORM, + category: "frame" + }, + { + name: "u_Time", + type: ShaderDataType.FLOAT, + qualifier: ShaderQualifier.UNIFORM, + category: "frame", + defaultValue: 0.0 + }, + { + name: "u_Color", + type: ShaderDataType.VEC4, + qualifier: ShaderQualifier.UNIFORM, + category: "material", + defaultValue: [1.0, 1.0, 1.0, 1.0] + }, + { + name: "u_WaveAmplitude", + type: ShaderDataType.FLOAT, + qualifier: ShaderQualifier.UNIFORM, + category: "material", + defaultValue: 0.1 + }, + { + name: "u_WaveFrequency", + type: ShaderDataType.FLOAT, + qualifier: ShaderQualifier.UNIFORM, + category: "material", + defaultValue: 2.0 + } + ], + + textures: [ + { + name: "u_MainTexture", + type: "texture2D", + slot: 0, + defaultTexture: "white" + } + ], + + varyings: [ + { + name: "v_TexCoord", + type: ShaderDataType.VEC2, + qualifier: ShaderQualifier.VARYING + } + ], + + passes: [ + { + name: "WaveEffect", + stage: [ShaderStage.VERTEX, ShaderStage.FRAGMENT], + vertexShader: ` +void main() { + vec3 pos = a_Position; + + #ifdef WAVE_EFFECT + + float wave = sin(pos.x * u_WaveFrequency + u_Time) * u_WaveAmplitude; + pos.y += wave; + #endif + + gl_Position = u_MVPMatrix * vec4(pos, 1.0); + v_TexCoord = a_TexCoord; +}`, + fragmentShader: ` +void main() { + vec4 color = u_Color; + + #ifdef MAIN_TEXTURE + color *= texture(u_MainTexture, v_TexCoord); + #endif + + #ifdef ANIMATED_COLOR + + color.rgb *= (sin(u_Time) * 0.5 + 0.5); + #endif + + gl_FragColor = color; +}`, + renderState: { + depthTest: true, + depthWrite: true, + depthFunc: DepthFunc.LEQUAL, + cullMode: CullMode.BACK, + blendMode: BlendMode.OPAQUE + } + } + ], + + keywords: ["WAVE_EFFECT", "MAIN_TEXTURE", "ANIMATED_COLOR"], + + optimization: { + level: "basic", + preservePrecision: true, + removeUnusedVariables: true, + inlineConstants: true + } +}; + +async function advancedShaderUsage() { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2'); + if (!gl) { + throw new Error('WebGL2 not supported'); + } + + const shaderManager = new ShaderManager(gl); + + const customShader = await shaderManager.loadFromConfiguration(CustomEffectShader); + + const material = shaderManager.createMaterial('Custom/Effect', { + u_Color: [0.2, 0.8, 1.0, 1.0], + u_WaveAmplitude: 0.05, + u_WaveFrequency: 4.0 + }); + + material.enableKeyword('WAVE_EFFECT'); + material.enableKeyword('ANIMATED_COLOR'); + + function animate(time: number) { + + material.setProperty('u_Time', time * 0.001); + + const amplitude = Math.sin(time * 0.002) * 0.1 + 0.05; + material.setProperty('u_WaveAmplitude', amplitude); + + material.apply(); + + requestAnimationFrame(animate); + } + + requestAnimationFrame(animate); + + return { customShader, material, shaderManager }; +} + +const shaderConfigJSON = `{ + "name": "UI/Text", + "version": "1.0.0", + "description": "Text rendering shader with SDF support", + "author": "Axrone Engine Team", + "tags": ["ui", "text", "sdf"], + "category": "UI", + + "attributes": [ + { + "name": "a_Position", + "type": "vec3", + "qualifier": "attribute", + "binding": 0, + "semantic": "POSITION" + }, + { + "name": "a_TexCoord", + "type": "vec2", + "qualifier": "attribute", + "binding": 1, + "semantic": "TEXCOORD" + }, + { + "name": "a_Color", + "type": "vec4", + "qualifier": "attribute", + "binding": 2, + "semantic": "COLOR" + } + ], + + "uniforms": [ + { + "name": "u_MVPMatrix", + "type": "mat4", + "qualifier": "uniform", + "category": "frame" + }, + { + "name": "u_TextColor", + "type": "vec4", + "qualifier": "uniform", + "category": "material", + "defaultValue": [1.0, 1.0, 1.0, 1.0] + }, + { + "name": "u_Smoothing", + "type": "float", + "qualifier": "uniform", + "category": "material", + "defaultValue": 0.1 + } + ], + + "textures": [ + { + "name": "u_FontTexture", + "type": "texture2D", + "slot": 0, + "filterMin": "linear", + "filterMag": "linear" + } + ], + + "varyings": [ + { + "name": "v_TexCoord", + "type": "vec2", + "qualifier": "varying" + }, + { + "name": "v_Color", + "type": "vec4", + "qualifier": "varying" + } + ], + + "passes": [ + { + "name": "TextRendering", + "stage": ["vertex", "fragment"], + "vertexShader": "void main() { gl_Position = u_MVPMatrix * vec4(a_Position, 1.0); v_TexCoord = a_TexCoord; v_Color = a_Color; }", + "fragmentShader": "void main() { float sdf = texture(u_FontTexture, v_TexCoord).r; float alpha = smoothstep(0.5 - u_Smoothing, 0.5 + u_Smoothing, sdf); gl_FragColor = vec4(u_TextColor.rgb * v_Color.rgb, alpha * u_TextColor.a * v_Color.a); }", + "renderState": { + "depthTest": false, + "depthWrite": false, + "blendMode": "alpha_blend" + } + } + ], + + "keywords": ["SDF_TEXT", "OUTLINE", "SHADOW"], + + "optimization": { + "level": "basic", + "preservePrecision": true, + "removeUnusedVariables": true, + "inlineConstants": false + } +}`; + +async function loadShaderFromJSON() { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2'); + if (!gl) { + throw new Error('WebGL2 not supported'); + } + + const shaderManager = new ShaderManager(gl); + + const textShader = await shaderManager.loadFromJSON(shaderConfigJSON); + + const textMaterial = shaderManager.createMaterial('UI/Text', { + u_TextColor: [1.0, 1.0, 1.0, 1.0], + u_Smoothing: 0.05 + }); + + textMaterial.enableKeyword('SDF_TEXT'); + + console.log('Text shader loaded successfully!'); + return { textShader, textMaterial, shaderManager }; +} + +function monitorShaderPerformance(shaderManager: ShaderManager) { + + const cacheInfo = shaderManager.getCacheInfo(); + + console.log('Shader Cache Statistics:'); + console.log(`- Total Shaders: ${cacheInfo.totalShaders}`); + console.log(`- Total Variants: ${cacheInfo.totalVariants}`); + console.log(`- Cache Hit Rate: ${(cacheInfo.hitRate * 100).toFixed(2)}%`); + console.log(`- Memory Usage: ${(cacheInfo.totalMemory / 1024).toFixed(2)} KB`); + console.log(`- Average Compilation Time: ${cacheInfo.averageCompilationTime.toFixed(2)} ms`); + + console.log('\nMost Accessed Shaders:'); + cacheInfo.shaders.slice(0, 5).forEach((shader, index) => { + console.log(`${index + 1}. ${shader.name} (${shader.accessCount} accesses, ${shader.variants} variants)`); + }); +} + +export { + basicShaderUsage, + advancedShaderUsage, + loadShaderFromJSON, + monitorShaderPerformance, + CustomEffectShader +}; diff --git a/packages/core/src/renderer/webgl2/shader/index.ts b/packages/core/src/renderer/webgl2/shader/index.ts new file mode 100644 index 0000000..c07c4d4 --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/index.ts @@ -0,0 +1,35 @@ +export * from './interfaces'; +export * from './utils'; + +export { WebGLShaderCompiler } from './compiler'; +export { ShaderManager } from './manager'; + +export { ShaderInstance } from './instance'; +export { MaterialInstance } from './material'; + +export { + generateVersionDirective, + generatePrecisionDirective, + generateDefines, + hashShaderSource, + generateVariantKey, + getShaderDataTypeSize, + getShaderDataTypeComponentCount, + getWebGLType, + calculateUniformBufferLayout, + VERTEX_SEMANTICS, + UNIFORM_SEMANTICS, + SHADER_KEYWORDS, + MAX_VERTEX_ATTRIBUTES, + MAX_TEXTURE_UNITS, + SHADER_CACHE_LIMITS +} from './utils'; + +export { + ShaderDataType, + ShaderQualifier, + ShaderStage, + BlendMode, + CullMode, + DepthFunc +} from './interfaces'; diff --git a/packages/core/src/renderer/webgl2/shader/instance.ts b/packages/core/src/renderer/webgl2/shader/instance.ts new file mode 100644 index 0000000..4b7f673 --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/instance.ts @@ -0,0 +1,418 @@ +import { + IShaderInstance, + ICompiledShader, + IShaderVariant, + ShaderUniformValue, + IUniformBlock, + ShaderDataType +} from './interfaces'; + +import { getWebGLType, getShaderDataTypeComponentCount } from './utils'; +import { ByteBuffer } from '@axrone/utility'; +import { Mat4, Vec2, Vec3, Vec4 } from '@axrone/numeric'; + +class UniformUploader { + private readonly gl: WebGL2RenderingContext; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + } + + uploadUniform(location: WebGLUniformLocation, type: ShaderDataType, value: ShaderUniformValue): void { + if (value === null || value === undefined) { + return; + } + + switch (type) { + case ShaderDataType.FLOAT: + this.gl.uniform1f(location, value as number); + break; + + case ShaderDataType.VEC2: + if (value instanceof Vec2) { + this.gl.uniform2f(location, value.x, value.y); + } else if (value instanceof Float32Array) { + this.gl.uniform2fv(location, value); + } else if (Array.isArray(value)) { + this.gl.uniform2f(location, value[0], value[1]); + } + break; + + case ShaderDataType.VEC3: + if (value instanceof Vec3) { + this.gl.uniform3f(location, value.x, value.y, value.z); + } else if (value instanceof Float32Array) { + this.gl.uniform3fv(location, value); + } else if (Array.isArray(value)) { + this.gl.uniform3f(location, value[0], value[1], value[2]); + } + break; + + case ShaderDataType.VEC4: + if (value instanceof Vec4) { + this.gl.uniform4f(location, value.x, value.y, value.z, value.w); + } else if (value instanceof Float32Array) { + this.gl.uniform4fv(location, value); + } else if (Array.isArray(value)) { + this.gl.uniform4f(location, value[0], value[1], value[2], value[3]); + } + break; + + case ShaderDataType.MAT4: + if (value instanceof Mat4) { + this.gl.uniformMatrix4fv(location, false, value.data); + } else if (value instanceof Float32Array) { + this.gl.uniformMatrix4fv(location, false, value); + } + break; + + case ShaderDataType.INT: + case ShaderDataType.BOOL: + this.gl.uniform1i(location, value as number); + break; + + case ShaderDataType.IVEC2: + if (value instanceof Int32Array) { + this.gl.uniform2iv(location, value); + } else if (Array.isArray(value)) { + this.gl.uniform2i(location, value[0], value[1]); + } + break; + + case ShaderDataType.IVEC3: + if (value instanceof Int32Array) { + this.gl.uniform3iv(location, value); + } else if (Array.isArray(value)) { + this.gl.uniform3i(location, value[0], value[1], value[2]); + } + break; + + case ShaderDataType.IVEC4: + if (value instanceof Int32Array) { + this.gl.uniform4iv(location, value); + } else if (Array.isArray(value)) { + this.gl.uniform4i(location, value[0], value[1], value[2], value[3]); + } + break; + + case ShaderDataType.SAMPLER_2D: + case ShaderDataType.SAMPLER_CUBE: + case ShaderDataType.SAMPLER_2D_ARRAY: + this.gl.uniform1i(location, value as number); + break; + + default: + console.warn(`Unsupported uniform type: ${type}`); + } + } +} + +export class ShaderInstance implements IShaderInstance { + public readonly shader: ICompiledShader; + public readonly variant: IShaderVariant; + public readonly uniforms = new Map(); + public readonly textures = new Map(); + public readonly uniformBuffers = new Map(); + + private readonly gl: WebGL2RenderingContext; + private readonly uniformUploader: UniformUploader; + private readonly boundTextureUnits = new Set(); + private readonly dirtyUniforms = new Set(); + private readonly dirtyBuffers = new Set(); + + private lastProgramBind = 0; + private uniformUpdateCount = 0; + + constructor(shader: ICompiledShader, variant: IShaderVariant) { + this.shader = shader; + this.variant = variant; + this.gl = this.getWebGLContext(shader.program); + this.uniformUploader = new UniformUploader(this.gl); + + this.initializeDefaultValues(); + } + + setUniform(name: string, value: ShaderUniformValue): void { + if (!this.shader.uniformLocations.has(name)) { + console.warn(`Uniform "${name}" not found in shader "${this.shader.name}"`); + return; + } + + const currentValue = this.uniforms.get(name); + if (currentValue !== undefined && this.isUniformValueEqual(currentValue, value)) { + return; + } + + this.uniforms.set(name, value); + this.dirtyUniforms.add(name); + } + + setTexture(name: string, texture: WebGLTexture): void { + if (!this.shader.textureSlots.has(name)) { + console.warn(`Texture "${name}" not found in shader "${this.shader.name}"`); + return; + } + + this.textures.set(name, texture); + } + + setUniformBuffer(name: string, buffer: ByteBuffer): void { + if (!this.shader.uniformBlocks.has(name)) { + console.warn(`Uniform buffer "${name}" not found in shader "${this.shader.name}"`); + return; + } + + this.uniformBuffers.set(name, buffer); + this.dirtyBuffers.add(name); + } + + bind(gl: WebGL2RenderingContext): void { + + if (this.lastProgramBind !== this.variant.shader.program) { + gl.useProgram(this.variant.shader.program); + this.lastProgramBind = this.variant.shader.program as any; + } + + this.updateUniforms(); + + this.bindTextures(); + + this.updateUniformBuffers(); + + this.applyRenderState(gl); + } + + unbind(gl: WebGL2RenderingContext): void { + + for (const unit of this.boundTextureUnits) { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, null); + } + this.boundTextureUnits.clear(); + } + + getUniform(name: string): ShaderUniformValue { + return this.uniforms.get(name) || null; + } + + hasUniform(name: string): boolean { + return this.shader.uniformLocations.has(name); + } + + getUniformNames(): string[] { + return Array.from(this.shader.uniformLocations.keys()); + } + + getRenderState() { + return this.shader.renderState; + } + + getStats() { + return { + uniformUpdateCount: this.uniformUpdateCount, + dirtyUniforms: this.dirtyUniforms.size, + dirtyBuffers: this.dirtyBuffers.size, + boundTextures: this.textures.size + }; + } + + private initializeDefaultValues(): void { + + for (const uniform of this.shader.configuration.uniforms) { + if (uniform.defaultValue !== undefined) { + this.uniforms.set(uniform.name, uniform.defaultValue); + } + } + + for (const [name, block] of this.shader.uniformBlocks) { + if (block.buffer) { + this.uniformBuffers.set(name, block.buffer); + } + } + } + + private updateUniforms(): void { + if (this.dirtyUniforms.size === 0) { + return; + } + + for (const uniformName of this.dirtyUniforms) { + const location = this.shader.uniformLocations.get(uniformName); + const value = this.uniforms.get(uniformName); + + if (location && value !== undefined) { + + const uniformConfig = this.shader.configuration.uniforms.find(u => u.name === uniformName); + if (uniformConfig) { + this.uniformUploader.uploadUniform(location, uniformConfig.type, value); + this.uniformUpdateCount++; + } + } + } + + this.dirtyUniforms.clear(); + } + + private bindTextures(): void { + for (const [textureName, texture] of this.textures) { + const slot = this.shader.textureSlots.get(textureName); + const location = this.shader.uniformLocations.get(textureName); + + if (slot !== undefined && location) { + this.gl.activeTexture(this.gl.TEXTURE0 + slot); + this.gl.bindTexture(this.gl.TEXTURE_2D, texture); + this.gl.uniform1i(location, slot); + this.boundTextureUnits.add(slot); + } + } + } + + private updateUniformBuffers(): void { + if (this.dirtyBuffers.size === 0) { + return; + } + + for (const bufferName of this.dirtyBuffers) { + const block = this.shader.uniformBlocks.get(bufferName); + const buffer = this.uniformBuffers.get(bufferName); + + if (block && buffer) { + + let glBuffer = this.gl.createBuffer(); + if (glBuffer) { + this.gl.bindBuffer(this.gl.UNIFORM_BUFFER, glBuffer); + + const bufferData = buffer as any; + this.gl.bufferData(this.gl.UNIFORM_BUFFER, bufferData.buffer || bufferData, this.gl.DYNAMIC_DRAW); + this.gl.bindBufferBase(this.gl.UNIFORM_BUFFER, block.binding, glBuffer); + } + } + } + + this.dirtyBuffers.clear(); + } + + private applyRenderState(gl: WebGL2RenderingContext): void { + const state = this.shader.renderState; + + if (state.depthTest !== undefined) { + if (state.depthTest) { + gl.enable(gl.DEPTH_TEST); + if (state.depthFunc) { + gl.depthFunc(this.getDepthFunc(gl, state.depthFunc)); + } + } else { + gl.disable(gl.DEPTH_TEST); + } + } + + if (state.depthWrite !== undefined) { + gl.depthMask(state.depthWrite); + } + + if (state.cullMode !== undefined) { + if (state.cullMode === 'off') { + gl.disable(gl.CULL_FACE); + } else { + gl.enable(gl.CULL_FACE); + gl.cullFace(state.cullMode === 'front' ? gl.FRONT : gl.BACK); + } + } + + if (state.blendMode !== undefined) { + if (state.blendMode === 'opaque') { + gl.disable(gl.BLEND); + } else { + gl.enable(gl.BLEND); + this.setBlendMode(gl, state.blendMode); + } + } + + if (state.colorWrite) { + gl.colorMask( + state.colorWrite[0], + state.colorWrite[1], + state.colorWrite[2], + state.colorWrite[3] + ); + } + } + + private getDepthFunc(gl: WebGL2RenderingContext, func: string): number { + switch (func) { + case 'never': return gl.NEVER; + case 'less': return gl.LESS; + case 'equal': return gl.EQUAL; + case 'lequal': return gl.LEQUAL; + case 'greater': return gl.GREATER; + case 'notequal': return gl.NOTEQUAL; + case 'gequal': return gl.GEQUAL; + case 'always': return gl.ALWAYS; + default: return gl.LESS; + } + } + + private setBlendMode(gl: WebGL2RenderingContext, mode: string): void { + switch (mode) { + case 'alpha_blend': + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + break; + case 'additive': + gl.blendFunc(gl.ONE, gl.ONE); + break; + case 'multiply': + gl.blendFunc(gl.DST_COLOR, gl.ZERO); + break; + case 'screen': + gl.blendFunc(gl.ONE_MINUS_DST_COLOR, gl.ONE); + break; + default: + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + } + } + + private isUniformValueEqual(a: ShaderUniformValue, b: ShaderUniformValue): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + + if (a instanceof Float32Array && b instanceof Float32Array) { + return this.areArraysEqual(a, b); + } + if (a instanceof Int32Array && b instanceof Int32Array) { + return this.areArraysEqual(a, b); + } + if (a instanceof Vec2 && b instanceof Vec2) { + return a.equals(b); + } + if (a instanceof Vec3 && b instanceof Vec3) { + return a.equals(b); + } + if (a instanceof Vec4 && b instanceof Vec4) { + return a.equals(b); + } + if (a instanceof Mat4 && b instanceof Mat4) { + return a.equals(b); + } + + return false; + } + + private areArraysEqual(a: ArrayLike, b: ArrayLike): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (Math.abs(a[i] - b[i]) > 1e-6) return false; + } + return true; + } + + private getWebGLContext(program: WebGLProgram): WebGL2RenderingContext { + + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2'); + if (!gl) { + throw new Error('WebGL2 not supported'); + } + return gl; + } +} diff --git a/packages/core/src/renderer/webgl2/shader/interfaces.ts b/packages/core/src/renderer/webgl2/shader/interfaces.ts new file mode 100644 index 0000000..9e33806 --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/interfaces.ts @@ -0,0 +1,339 @@ +import { Mat4, Vec2, Vec3, Vec4 } from '@axrone/numeric'; +import { ByteBuffer } from '@axrone/utility'; + +export const enum ShaderDataType { + FLOAT = 'float', + VEC2 = 'vec2', + VEC3 = 'vec3', + VEC4 = 'vec4', + MAT2 = 'mat2', + MAT3 = 'mat3', + MAT4 = 'mat4', + INT = 'int', + IVEC2 = 'ivec2', + IVEC3 = 'ivec3', + IVEC4 = 'ivec4', + UINT = 'uint', + UVEC2 = 'uvec2', + UVEC3 = 'uvec3', + UVEC4 = 'uvec4', + BOOL = 'bool', + BVEC2 = 'bvec2', + BVEC3 = 'bvec3', + BVEC4 = 'bvec4', + SAMPLER_2D = 'sampler2D', + SAMPLER_CUBE = 'samplerCube', + SAMPLER_2D_ARRAY = 'sampler2DArray' +} + +export const enum ShaderQualifier { + ATTRIBUTE = 'attribute', + UNIFORM = 'uniform', + VARYING = 'varying', + CONST = 'const', + IN = 'in', + OUT = 'out', + INOUT = 'inout' +} + +export const enum ShaderStage { + VERTEX = 'vertex', + FRAGMENT = 'fragment', + GEOMETRY = 'geometry', + TESSELLATION_CONTROL = 'tessellation_control', + TESSELLATION_EVALUATION = 'tessellation_evaluation', + COMPUTE = 'compute' +} + +export const enum BlendMode { + OPAQUE = 'opaque', + ALPHA_BLEND = 'alpha_blend', + ADDITIVE = 'additive', + MULTIPLY = 'multiply', + SCREEN = 'screen', + OVERLAY = 'overlay' +} + +export const enum CullMode { + OFF = 'off', + FRONT = 'front', + BACK = 'back' +} + +export const enum DepthFunc { + NEVER = 'never', + LESS = 'less', + EQUAL = 'equal', + LEQUAL = 'lequal', + GREATER = 'greater', + NOTEQUAL = 'notequal', + GEQUAL = 'gequal', + ALWAYS = 'always' +} + +export interface IShaderVariable { + readonly name: string; + readonly type: ShaderDataType; + readonly qualifier: ShaderQualifier; + readonly location?: number; + readonly defaultValue?: ShaderUniformValue; + readonly semantic?: string; + readonly precision?: 'lowp' | 'mediump' | 'highp'; + readonly arraySize?: number; +} + +export interface IVertexAttribute extends IShaderVariable { + readonly qualifier: ShaderQualifier.ATTRIBUTE | ShaderQualifier.IN; + readonly binding: number; + readonly stride?: number; + readonly offset?: number; + readonly normalized?: boolean; + readonly divisor?: number; +} + +export interface IUniformVariable extends IShaderVariable { + readonly qualifier: ShaderQualifier.UNIFORM; + readonly binding?: number; + readonly bufferOffset?: number; + readonly category?: 'material' | 'frame' | 'camera' | 'object' | 'lighting'; +} + +export interface IVaryingVariable extends IShaderVariable { + readonly qualifier: ShaderQualifier.VARYING | ShaderQualifier.IN | ShaderQualifier.OUT; + readonly interpolation?: 'smooth' | 'flat' | 'noperspective'; +} + +export type ShaderUniformValue = + | number + | boolean + | Vec2 + | Vec3 + | Vec4 + | Mat4 + | Float32Array + | Int32Array + | Uint32Array + | WebGLTexture + | null; + +export interface IUniformBlock { + readonly name: string; + readonly binding: number; + readonly variables: readonly IUniformVariable[]; + readonly size: number; + readonly buffer?: ByteBuffer; +} + +export interface ITextureProperty { + readonly name: string; + readonly type: 'texture2D' | 'textureCube' | 'texture2DArray'; + readonly slot: number; + readonly defaultTexture?: string; + readonly wrapS?: 'repeat' | 'clamp' | 'mirror'; + readonly wrapT?: 'repeat' | 'clamp' | 'mirror'; + readonly filterMin?: 'nearest' | 'linear' | 'nearest_mipmap_nearest' | 'linear_mipmap_nearest' | 'nearest_mipmap_linear' | 'linear_mipmap_linear'; + readonly filterMag?: 'nearest' | 'linear'; + readonly anisotropy?: number; + readonly sRGB?: boolean; +} + +export interface IRenderState { + readonly depthTest?: boolean; + readonly depthWrite?: boolean; + readonly depthFunc?: DepthFunc; + readonly cullMode?: CullMode; + readonly blendMode?: BlendMode; + readonly blendSrc?: number; + readonly blendDst?: number; + readonly blendEquation?: number; + readonly colorWrite?: [boolean, boolean, boolean, boolean]; + readonly stencilTest?: boolean; + readonly stencilFunc?: number; + readonly stencilRef?: number; + readonly stencilMask?: number; + readonly stencilFail?: number; + readonly stencilZFail?: number; + readonly stencilZPass?: number; + readonly polygonOffset?: [number, number]; + readonly scissorTest?: boolean; +} + +export interface IShaderPass { + readonly name: string; + readonly stage: ShaderStage[]; + readonly vertexShader: string; + readonly fragmentShader?: string; + readonly geometryShader?: string; + readonly tessellationControlShader?: string; + readonly tessellationEvaluationShader?: string; + readonly computeShader?: string; + readonly renderState: IRenderState; + readonly defines?: Record; + readonly keywords?: string[]; +} + +export interface IShaderConfiguration { + readonly name: string; + readonly version: string; + readonly description?: string; + readonly author?: string; + readonly tags?: string[]; + readonly category?: string; + readonly fallback?: string; + readonly attributes: readonly IVertexAttribute[]; + readonly uniforms: readonly IUniformVariable[]; + readonly uniformBlocks?: readonly IUniformBlock[]; + readonly textures: readonly ITextureProperty[]; + readonly varyings?: readonly IVaryingVariable[]; + readonly passes: readonly IShaderPass[]; + readonly defines?: Record; + readonly keywords?: string[]; + readonly includes?: string[]; + readonly optimization?: { + readonly level: 'none' | 'basic' | 'aggressive'; + readonly preservePrecision?: boolean; + readonly removeUnusedVariables?: boolean; + readonly inlineConstants?: boolean; + }; +} + +export interface ICompiledShader { + readonly id: string; + readonly name: string; + readonly configuration: IShaderConfiguration; + readonly program: WebGLProgram; + readonly uniformLocations: Map; + readonly attributeLocations: Map; + readonly uniformBlocks: Map; + readonly textureSlots: Map; + readonly renderState: IRenderState; + readonly bytecodeSize: number; + readonly compilationTime: number; +} + +export interface IShaderVariant { + readonly keywords: readonly string[]; + readonly defines: Record; + readonly hash: string; + readonly shader: ICompiledShader; +} + +export interface IShaderInstance { + readonly shader: ICompiledShader; + readonly variant: IShaderVariant; + readonly uniforms: Map; + readonly textures: Map; + readonly uniformBuffers: Map; + + setUniform(name: string, value: ShaderUniformValue): void; + setTexture(name: string, texture: WebGLTexture): void; + setUniformBuffer(name: string, buffer: ByteBuffer): void; + hasUniform(name: string): boolean; + getUniform(name: string): ShaderUniformValue; + bind(gl: WebGL2RenderingContext): void; + unbind(gl: WebGL2RenderingContext): void; +} + +export interface IMaterialInstance { + readonly shader: IShaderInstance; + readonly properties: Map; + + setProperty(name: string, value: ShaderUniformValue): void; + getProperty(name: string): ShaderUniformValue; + hasProperty(name: string): boolean; + enableKeyword(keyword: string): void; + disableKeyword(keyword: string): void; + hasKeyword(keyword: string): boolean; + getEnabledKeywords(): string[]; + apply(): void; + clone(): IMaterialInstance; +} + +export interface IShaderCompilerOptions { + readonly enableOptimization?: boolean; + readonly preserveDebugInfo?: boolean; + readonly validateInputs?: boolean; + readonly generateReflection?: boolean; + readonly targetVersion?: '300 es' | '310 es' | '320 es'; +} + +export interface IShaderCompiler { + compile(configuration: IShaderConfiguration, options?: IShaderCompilerOptions): Promise; + compileVariant(shader: ICompiledShader, keywords: string[], defines: Record): Promise; + validateConfiguration(configuration: IShaderConfiguration): ValidationResult; +} + +export interface ValidationResult { + readonly isValid: boolean; + readonly errors: string[]; + readonly warnings: string[]; +} + +export interface IShaderManager { + loadFromJSON(json: string): Promise; + loadFromFile(path: string): Promise; + loadFromConfiguration(configuration: IShaderConfiguration): Promise; + createMaterial(shaderName: string, properties?: Record): IMaterialInstance; + getShader(name: string): ICompiledShader | null; + getVariant(shader: ICompiledShader, keywords: string[]): Promise; + dispose(shader: ICompiledShader): void; + disposeAll(): void; +} + +export interface IShaderProfiler { + readonly gpuTime: number; + readonly cpuTime: number; + readonly drawCalls: number; + readonly uniformUpdates: number; + readonly textureBinds: number; + readonly shaderSwitches: number; + + beginFrame(): void; + endFrame(): void; + reset(): void; +} + +export interface IShaderDebugger { + captureFrame(): Promise; + inspectShader(shader: ICompiledShader): ShaderInspection; + validateUniforms(instance: IShaderInstance): ValidationResult; +} + +export interface FrameCapture { + readonly timestamp: number; + readonly drawCalls: DrawCallInfo[]; + readonly shaderSwitches: ShaderSwitchInfo[]; + readonly uniformUpdates: UniformUpdateInfo[]; +} + +export interface DrawCallInfo { + readonly shaderId: string; + readonly primitiveCount: number; + readonly vertexCount: number; + readonly instanceCount: number; + readonly gpuTime: number; +} + +export interface ShaderSwitchInfo { + readonly fromShader: string; + readonly toShader: string; + readonly timestamp: number; + readonly cost: number; +} + +export interface UniformUpdateInfo { + readonly uniformName: string; + readonly value: ShaderUniformValue; + readonly timestamp: number; + readonly frequency: number; +} + +export interface ShaderInspection { + readonly complexity: number; + readonly instructionCount: number; + readonly registerUsage: number; + readonly textureReads: number; + readonly branches: number; + readonly loops: number; + readonly hotspots: string[]; +} \ No newline at end of file diff --git a/packages/core/src/renderer/webgl2/shader/manager.ts b/packages/core/src/renderer/webgl2/shader/manager.ts new file mode 100644 index 0000000..4388892 --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/manager.ts @@ -0,0 +1,366 @@ +import { ObjectPool } from '@axrone/utility'; +import { + IShaderManager, + IShaderConfiguration, + ICompiledShader, + IShaderVariant, + IMaterialInstance, + ShaderUniformValue, + IShaderCompiler +} from './interfaces'; + +import { WebGLShaderCompiler } from './compiler'; +import { ShaderInstance } from './instance'; +import { MaterialInstance } from './material'; +import { generateVariantKey, SHADER_CACHE_LIMITS } from './utils'; + +interface ShaderCacheEntry { + readonly shader: ICompiledShader; + readonly variants: Map; + lastAccessed: number; + accessCount: number; +} + +interface ShaderManagerStats { + loadedShaders: number; + totalVariants: number; + cacheHits: number; + cacheMisses: number; + memoryUsage: number; + compilationTime: number; + hitRate: number; +} + +export class ShaderManager implements IShaderManager { + private readonly gl: WebGL2RenderingContext; + private readonly compiler: IShaderCompiler; + private readonly shaderCache = new Map(); + private readonly configurationCache = new Map(); + private readonly includeCache = new Map(); + private readonly materialPool: ObjectPool; + + private stats: ShaderManagerStats = { + loadedShaders: 0, + totalVariants: 0, + cacheHits: 0, + cacheMisses: 0, + memoryUsage: 0, + compilationTime: 0, + hitRate: 0 + }; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.compiler = new WebGLShaderCompiler(gl); + + this.materialPool = new ObjectPool({ + factory: () => new MaterialInstance(null as any), + resetHandler: (material) => { + } + }); + } + + async loadFromJSON(json: string): Promise { + const startTime = performance.now(); + + try { + const configuration: IShaderConfiguration = JSON.parse(json); + return await this.loadFromConfiguration(configuration); + } catch (error) { + throw new Error(`Failed to parse shader JSON: ${error}`); + } finally { + this.stats.compilationTime += performance.now() - startTime; + } + } + + async loadFromFile(path: string): Promise { + const startTime = performance.now(); + + try { + + if (this.configurationCache.has(path)) { + const configuration = this.configurationCache.get(path)!; + return await this.loadFromConfiguration(configuration); + } + + const response = await fetch(path); + if (!response.ok) { + throw new Error(`Failed to load shader file: ${response.statusText}`); + } + + const json = await response.text(); + const configuration: IShaderConfiguration = JSON.parse(json); + + this.configurationCache.set(path, configuration); + + return await this.loadFromConfiguration(configuration); + } catch (error) { + throw new Error(`Failed to load shader from file "${path}": ${error}`); + } finally { + this.stats.compilationTime += performance.now() - startTime; + } + } + + async loadFromConfiguration(configuration: IShaderConfiguration): Promise { + const cacheKey = configuration.name; + const cachedEntry = this.shaderCache.get(cacheKey); + + if (cachedEntry) { + cachedEntry.lastAccessed = Date.now(); + cachedEntry.accessCount++; + this.stats.cacheHits++; + this.updateStats(); + return cachedEntry.shader; + } + + this.stats.cacheMisses++; + + const shader = await this.compiler.compile(configuration); + + const entry: ShaderCacheEntry = { + shader, + variants: new Map(), + lastAccessed: Date.now(), + accessCount: 1 + }; + + this.shaderCache.set(cacheKey, entry); + this.stats.loadedShaders++; + this.updateStats(); + + return shader; + } + + createMaterial( + shaderName: string, + properties: Record = {} + ): IMaterialInstance { + const shader = this.getShader(shaderName); + if (!shader) { + throw new Error(`Shader "${shaderName}" not found`); + } + + const instance = new ShaderInstance(shader, { + keywords: [], + defines: {}, + hash: generateVariantKey(shaderName, [], {}), + shader + }); + + const material = new MaterialInstance(instance); + + for (const [name, value] of Object.entries(properties)) { + material.setProperty(name, value); + } + + return material; + } + + getShader(name: string): ICompiledShader | null { + const entry = this.shaderCache.get(name); + if (entry) { + entry.lastAccessed = Date.now(); + entry.accessCount++; + this.stats.cacheHits++; + this.updateStats(); + return entry.shader; + } + this.stats.cacheMisses++; + this.updateStats(); + return null; + } + + async getVariant( + shader: ICompiledShader, + keywords: string[] + ): Promise { + const entry = this.shaderCache.get(shader.name); + if (!entry) { + throw new Error(`Shader "${shader.name}" not found in cache`); + } + + const variantKey = generateVariantKey(shader.name, keywords, {}); + const cachedVariant = entry.variants.get(variantKey); + + if (cachedVariant) { + this.stats.cacheHits++; + this.updateStats(); + return cachedVariant; + } + + this.stats.cacheMisses++; + + const variant = await this.compiler.compileVariant(shader, keywords, {}); + entry.variants.set(variantKey, variant); + this.stats.totalVariants++; + this.updateStats(); + + return variant; + } + + dispose(shader: ICompiledShader): void { + const entry = this.shaderCache.get(shader.name); + if (entry) { + this.disposeShaderEntry(entry); + this.shaderCache.delete(shader.name); + this.stats.loadedShaders--; + this.updateStats(); + } + } + + disposeAll(): void { + for (const entry of this.shaderCache.values()) { + this.disposeShaderEntry(entry); + } + + this.shaderCache.clear(); + this.configurationCache.clear(); + this.includeCache.clear(); + + this.stats = { + loadedShaders: 0, + totalVariants: 0, + cacheHits: 0, + cacheMisses: 0, + memoryUsage: 0, + compilationTime: 0, + hitRate: 0 + }; + } + + async preloadIncludes(includes: Record): Promise { + const loadPromises = Object.entries(includes).map(async ([name, path]) => { + try { + const response = await fetch(path); + if (response.ok) { + const content = await response.text(); + this.includeCache.set(name, content); + } + } catch (error) { + console.warn(`Failed to preload include "${name}": ${error}`); + } + }); + + await Promise.all(loadPromises); + } + + getInclude(name: string): string | null { + return this.includeCache.get(name) || null; + } + + optimizeCache(): void { + // LRU caches handle eviction automatically, but we can trigger cleanup + const now = Date.now(); + const maxAge = 5 * 60 * 1000; // 5 minutes + const shadersToEvict: string[] = []; + + // Find old, rarely used shaders + for (const [name, entry] of this.shaderCache.entries()) { + const age = now - entry.lastAccessed; + if (age > maxAge && entry.accessCount < 5) { + shadersToEvict.push(name); + } + } + + // Remove old shaders + for (const name of shadersToEvict) { + this.shaderCache.delete(name); + } + + // Force garbage collection if memory usage is too high + if (this.stats.memoryUsage > SHADER_CACHE_LIMITS.MAX_CACHE_SIZE_BYTES) { + // Remove half of the least recently used shaders + const entries = Array.from(this.shaderCache.entries()) + .sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed); + + const toRemove = Math.floor(entries.length * 0.5); + for (let i = 0; i < toRemove; i++) { + const [key, entry] = entries[i]; + this.disposeShaderEntry(entry); + this.shaderCache.delete(key); + } + } + + this.updateStats(); + } + + getStats(): Readonly { + return { ...this.stats }; + } + + getCacheInfo() { + const shaders: Array<{ + name: string; + variants: number; + lastAccessed: number; + accessCount: number; + memorySize: number; + }> = []; + + for (const [name, entry] of this.shaderCache.entries()) { + let variantMemory = 0; + for (const variant of entry.variants.values()) { + variantMemory += variant.shader.bytecodeSize; + } + + shaders.push({ + name, + variants: entry.variants.size, + lastAccessed: entry.lastAccessed, + accessCount: entry.accessCount, + memorySize: entry.shader.bytecodeSize + variantMemory + }); + } + + return { + totalShaders: this.stats.loadedShaders, + totalVariants: this.stats.totalVariants, + totalMemory: this.stats.memoryUsage, + hitRate: this.stats.hitRate, + averageCompilationTime: this.stats.compilationTime / Math.max(1, this.stats.loadedShaders), + cacheEfficiency: { + shaderCacheSize: this.shaderCache.size, + shaderCacheMaxSize: SHADER_CACHE_LIMITS.MAX_COMPILED_SHADERS, + configCacheSize: this.configurationCache.size, + configCacheMaxSize: SHADER_CACHE_LIMITS.MAX_CONFIGURATIONS + }, + shaders: shaders.sort((a, b) => b.accessCount - a.accessCount) + }; + } + + + + private updateStats(): void { + let totalSize = 0; + let totalVariants = 0; + + for (const entry of this.shaderCache.values()) { + totalSize += entry.shader.bytecodeSize; + totalVariants += entry.variants.size; + + for (const variant of entry.variants.values()) { + totalSize += variant.shader.bytecodeSize; + } + } + + this.stats = { + ...this.stats, + memoryUsage: totalSize, + totalVariants, + hitRate: this.stats.cacheHits / Math.max(1, this.stats.cacheHits + this.stats.cacheMisses) + }; + } + + private disposeShaderEntry(entry: ShaderCacheEntry): void { + this.gl.deleteProgram(entry.shader.program); + + for (const variant of entry.variants.values()) { + this.disposeVariant(variant); + } + entry.variants.clear(); + } + + private disposeVariant(variant: IShaderVariant): void { + this.gl.deleteProgram(variant.shader.program); + } +} diff --git a/packages/core/src/renderer/webgl2/shader/material.ts b/packages/core/src/renderer/webgl2/shader/material.ts new file mode 100644 index 0000000..fb6330c --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/material.ts @@ -0,0 +1,341 @@ +import { + IMaterialInstance, + IShaderInstance, + ShaderUniformValue, + IShaderConfiguration +} from './interfaces'; + +import { generateVariantKey } from './utils'; + +interface MaterialProperty { + value: ShaderUniformValue; + lastModified: number; + isDirty: boolean; +} + +interface MaterialKeyword { + enabled: boolean; + lastModified: number; +} + +export class MaterialInstance implements IMaterialInstance { + public readonly shader: IShaderInstance; + public readonly properties = new Map(); + private readonly _materialProperties = new Map(); + private readonly _keywords = new Map(); + + private variantDirty = false; + private lastVariantUpdate = 0; + private readonly propertyAliases = new Map(); + + constructor(shader: IShaderInstance) { + this.shader = shader; + this.initializeProperties(); + this.setupPropertyAliases(); + } + + setProperty(name: string, value: ShaderUniformValue): void { + + const actualName = this.propertyAliases.get(name) || name; + + if (!this.shader.hasUniform(actualName)) { + console.warn(`Property "${name}" (${actualName}) not found in material`); + return; + } + + const currentProperty = this._materialProperties.get(actualName); + if (currentProperty && this.isValueEqual(currentProperty.value, value)) { + return; + } + + const now = performance.now(); + this._materialProperties.set(actualName, { + value, + lastModified: now, + isDirty: true + }); + + this.properties.set(actualName, value); + + this.shader.setUniform(actualName, value); + } + + getProperty(name: string): ShaderUniformValue { + const actualName = this.propertyAliases.get(name) || name; + return this.properties.get(actualName) || null; + } + + hasProperty(name: string): boolean { + const actualName = this.propertyAliases.get(name) || name; + return this.properties.has(actualName); + } + + enableKeyword(keyword: string): void { + const currentKeyword = this._keywords.get(keyword); + if (currentKeyword?.enabled) { + return; + } + + this._keywords.set(keyword, { + enabled: true, + lastModified: performance.now() + }); + + this.variantDirty = true; + } + + disableKeyword(keyword: string): void { + const currentKeyword = this._keywords.get(keyword); + if (!currentKeyword?.enabled) { + return; + } + + this._keywords.set(keyword, { + enabled: false, + lastModified: performance.now() + }); + + this.variantDirty = true; + } + + hasKeyword(keyword: string): boolean { + const keywordState = this._keywords.get(keyword); + return keywordState?.enabled || false; + } + + toggleKeyword(keyword: string): void { + if (this.hasKeyword(keyword)) { + this.disableKeyword(keyword); + } else { + this.enableKeyword(keyword); + } + } + + getEnabledKeywords(): string[] { + const enabled: string[] = []; + for (const [keyword, state] of this._keywords) { + if (state.enabled) { + enabled.push(keyword); + } + } + return enabled; + } + + clone(): IMaterialInstance { + const cloned = new MaterialInstance(this.shader); + + for (const [name, property] of this._materialProperties) { + cloned._materialProperties.set(name, { + value: this.deepCloneValue(property.value), + lastModified: property.lastModified, + isDirty: property.isDirty + }); + cloned.properties.set(name, property.value); + } + + for (const [keyword, state] of this._keywords) { + cloned._keywords.set(keyword, { ...state }); + } + + return cloned; + } + + setProperties(properties: Record): void { + for (const [name, value] of Object.entries(properties)) { + this.setProperty(name, value); + } + } + + getPropertyNames(): string[] { + return Array.from(this.properties.keys()); + } + + getDirtyProperties(): string[] { + const dirty: string[] = []; + for (const [name, property] of this._materialProperties) { + if (property.isDirty) { + dirty.push(name); + } + } + return dirty; + } + + markClean(): void { + for (const property of this._materialProperties.values()) { + property.isDirty = false; + } + this.variantDirty = false; + } + + needsVariantUpdate(): boolean { + return this.variantDirty; + } + + getVariantHash(): string { + const enabledKeywords = this.getEnabledKeywords(); + return generateVariantKey(this.shader.shader.name, enabledKeywords, {}); + } + + getStats() { + let dirtyCount = 0; + let totalProperties = 0; + let lastModified = 0; + + for (const property of this._materialProperties.values()) { + totalProperties++; + if (property.isDirty) dirtyCount++; + lastModified = Math.max(lastModified, property.lastModified); + } + + return { + totalProperties, + dirtyProperties: dirtyCount, + enabledKeywords: this.getEnabledKeywords().length, + totalKeywords: this._keywords.size, + lastModified, + variantDirty: this.variantDirty + }; + } + + validate(): { valid: boolean; errors: string[]; warnings: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + + const config = this.shader.shader.configuration; + for (const uniform of config.uniforms) { + if (!this.properties.has(uniform.name) && uniform.defaultValue === undefined) { + warnings.push(`Property "${uniform.name}" has no value and no default`); + } + } + + for (const propertyName of this.properties.keys()) { + if (!this.shader.hasUniform(propertyName)) { + warnings.push(`Property "${propertyName}" is not used by the shader`); + } + } + + const validKeywords = new Set(config.keywords || []); + for (const keyword of this._keywords.keys()) { + if (!validKeywords.has(keyword)) { + warnings.push(`Keyword "${keyword}" is not defined in shader`); + } + } + + return { + valid: errors.length === 0, + errors, + warnings + }; + } + + reset(): void { + this.properties.clear(); + this._materialProperties.clear(); + this._keywords.clear(); + this.initializeProperties(); + this.variantDirty = true; + } + + apply(): void { + + for (const [name, property] of this._materialProperties) { + if (property.isDirty) { + this.shader.setUniform(name, property.value); + property.isDirty = false; + } + } + + this.variantDirty = false; + this.lastVariantUpdate = performance.now(); + } + + private initializeProperties(): void { + const config = this.shader.shader.configuration; + + for (const uniform of config.uniforms) { + if (uniform.defaultValue !== undefined) { + this._materialProperties.set(uniform.name, { + value: uniform.defaultValue, + lastModified: 0, + isDirty: false + }); + this.properties.set(uniform.name, uniform.defaultValue); + } + } + + if (config.keywords) { + for (const keyword of config.keywords) { + this._keywords.set(keyword, { + enabled: false, + lastModified: 0 + }); + } + } + } + + private setupPropertyAliases(): void { + + this.propertyAliases.set('mainTexture', 'u_MainTexture'); + this.propertyAliases.set('color', 'u_Color'); + this.propertyAliases.set('tint', 'u_Color'); + this.propertyAliases.set('albedo', 'u_AlbedoColor'); + this.propertyAliases.set('emission', 'u_EmissionColor'); + this.propertyAliases.set('metallic', 'u_Metallic'); + this.propertyAliases.set('roughness', 'u_Roughness'); + this.propertyAliases.set('normalScale', 'u_NormalScale'); + this.propertyAliases.set('emissionIntensity', 'u_EmissionIntensity'); + } + + private isValueEqual(a: ShaderUniformValue, b: ShaderUniformValue): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + + if (a instanceof Float32Array && b instanceof Float32Array) { + return this.areArraysEqual(a, b); + } + if (a instanceof Int32Array && b instanceof Int32Array) { + return this.areArraysEqual(a, b); + } + + if (typeof a === 'object' && 'equals' in a && typeof a.equals === 'function') { + return a.equals(b); + } + + return false; + } + + private areArraysEqual(a: ArrayLike, b: ArrayLike): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (Math.abs(a[i] - b[i]) > 1e-6) return false; + } + return true; + } + + private deepCloneValue(value: ShaderUniformValue): ShaderUniformValue { + if (value === null || typeof value !== 'object') { + return value; + } + + if (value instanceof Float32Array) { + return new Float32Array(value); + } + if (value instanceof Int32Array) { + return new Int32Array(value); + } + if (value instanceof Uint32Array) { + return new Uint32Array(value); + } + + if ('clone' in value && typeof value.clone === 'function') { + return value.clone(); + } + + if (value instanceof WebGLTexture) { + return value; + } + + return value; + } +} diff --git a/packages/core/src/renderer/webgl2/shader/shader-compilation-worker.ts b/packages/core/src/renderer/webgl2/shader/shader-compilation-worker.ts new file mode 100644 index 0000000..7af3f9a --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/shader-compilation-worker.ts @@ -0,0 +1,105 @@ +export class ShaderCompilationWorker { + private readonly worker: Worker; + private readonly pendingCompilations = new Map void>(); + + constructor() { + const workerCode = ` + self.onmessage = function(e) { + const { id, source, type, options } = e.data; + + try { + // Process shader + const processed = processShader(source, type, options); + + // Send result back + self.postMessage({ + id, + success: true, + shader: processed + }); + } catch (error) { + // Send error + self.postMessage({ + id, + success: false, + error: error.message + }); + } + }; + + function processShader(source, type, options) { + const { defines = {}, version = '300 es', precision = 'highp' } = options || {}; + + // Add version + let result = \`#version \${version}\\n\`; + + // Add defines + for (const [key, value] of Object.entries(defines)) { + if (value === true) { + result += \`#define \${key}\\n\`; + } else if (value !== false) { + result += \`#define \${key} \${value}\\n\`; + } + } + + // Add precision for fragment shaders + if (type === 'fragment') { + result += \`precision \${precision} float;\\n\`; + } + + // Add source + result += source; + + return result; + } + `; + + const blob = new Blob([workerCode], { type: 'application/javascript' }); + const url = URL.createObjectURL(blob); + + this.worker = new Worker(url); + + URL.revokeObjectURL(url); + + this.worker.onmessage = this.handleMessage.bind(this); + } + + compile( + source: string, + type: 'vertex' | 'fragment', + options?: { defines?: Record; version?: string; precision?: string; } + ): Promise { + return new Promise((resolve, reject) => { + const id = `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + this.pendingCompilations.set(id, resolve); + + this.worker.postMessage({ + id, + source, + type, + options + }); + }); + } + + private handleMessage(event: MessageEvent): void { + const { id, success, shader, error } = event.data; + + const callback = this.pendingCompilations.get(id); + if (!callback) return; + + this.pendingCompilations.delete(id); + + if (success) { + callback(shader); + } else { + throw new Error(`Shader compilation failed: ${error}`); + } + } + + terminate(): void { + this.worker.terminate(); + this.pendingCompilations.clear(); + } +} diff --git a/packages/core/src/renderer/webgl2/shader/templates/standard-shaders.ts b/packages/core/src/renderer/webgl2/shader/templates/standard-shaders.ts new file mode 100644 index 0000000..5f7f5b3 --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/templates/standard-shaders.ts @@ -0,0 +1,123 @@ +import { + IShaderConfiguration, + ShaderDataType, + ShaderQualifier, + ShaderStage, + BlendMode, + CullMode, + DepthFunc +} from '../interfaces'; + +export const StandardUnlitShader: IShaderConfiguration = { + name: "Standard/Unlit", + version: "1.0.0", + description: "Basic unlit shader with color and texture support", + author: "Axrone Engine Team", + tags: ["unlit", "basic", "mobile-friendly"], + category: "Standard", + + attributes: [ + { + name: "a_Position", + type: ShaderDataType.VEC3, + qualifier: ShaderQualifier.ATTRIBUTE, + binding: 0, + semantic: "POSITION" + }, + { + name: "a_TexCoord", + type: ShaderDataType.VEC2, + qualifier: ShaderQualifier.ATTRIBUTE, + binding: 1, + semantic: "TEXCOORD", + defaultValue: null + } + ], + + uniforms: [ + { + name: "u_MVPMatrix", + type: ShaderDataType.MAT4, + qualifier: ShaderQualifier.UNIFORM, + semantic: "u_MVPMatrix", + category: "frame", + defaultValue: null + }, + { + name: "u_Color", + type: ShaderDataType.VEC4, + qualifier: ShaderQualifier.UNIFORM, + category: "material", + defaultValue: [1.0, 1.0, 1.0, 1.0], + precision: "highp" + }, + { + name: "u_MainTexture", + type: ShaderDataType.SAMPLER_2D, + qualifier: ShaderQualifier.UNIFORM, + category: "material", + defaultValue: null + } + ], + + textures: [ + { + name: "u_MainTexture", + type: "texture2D", + slot: 0, + defaultTexture: "white", + wrapS: "repeat", + wrapT: "repeat", + filterMin: "linear", + filterMag: "linear" + } + ], + + varyings: [ + { + name: "v_TexCoord", + type: ShaderDataType.VEC2, + qualifier: ShaderQualifier.VARYING, + interpolation: "smooth" + } + ], + + passes: [ + { + name: "ForwardBase", + stage: [ShaderStage.VERTEX, ShaderStage.FRAGMENT], + vertexShader: ` +void main() { + gl_Position = u_MVPMatrix * vec4(a_Position, 1.0); + v_TexCoord = a_TexCoord; +}`, + fragmentShader: ` +void main() { + vec4 color = u_Color; + + #ifdef MAIN_TEXTURE + color *= texture(u_MainTexture, v_TexCoord); + #endif + + gl_FragColor = color; +}`, + renderState: { + depthTest: true, + depthWrite: true, + depthFunc: DepthFunc.LEQUAL, + cullMode: CullMode.BACK, + blendMode: BlendMode.OPAQUE + }, + keywords: ["MAIN_TEXTURE"] + } + ], + + keywords: ["MAIN_TEXTURE"], + + optimization: { + level: "basic", + preservePrecision: true, + removeUnusedVariables: true, + inlineConstants: true + } +}; diff --git a/packages/core/src/renderer/webgl2/shader/utils.ts b/packages/core/src/renderer/webgl2/shader/utils.ts new file mode 100644 index 0000000..1e27664 --- /dev/null +++ b/packages/core/src/renderer/webgl2/shader/utils.ts @@ -0,0 +1,369 @@ +import { ShaderDataType, ShaderStage } from './interfaces'; + +export const getShaderDataTypeSize = (type: ShaderDataType): number => { + switch (type) { + case ShaderDataType.FLOAT: + case ShaderDataType.INT: + case ShaderDataType.UINT: + case ShaderDataType.BOOL: + return 4; + + case ShaderDataType.VEC2: + case ShaderDataType.IVEC2: + case ShaderDataType.UVEC2: + case ShaderDataType.BVEC2: + return 8; + + case ShaderDataType.VEC3: + case ShaderDataType.IVEC3: + case ShaderDataType.UVEC3: + case ShaderDataType.BVEC3: + return 12; + + case ShaderDataType.VEC4: + case ShaderDataType.IVEC4: + case ShaderDataType.UVEC4: + case ShaderDataType.BVEC4: + case ShaderDataType.MAT2: + return 16; + + case ShaderDataType.MAT3: + return 36; + + case ShaderDataType.MAT4: + return 64; + + case ShaderDataType.SAMPLER_2D: + case ShaderDataType.SAMPLER_CUBE: + case ShaderDataType.SAMPLER_2D_ARRAY: + return 4; + + default: + throw new Error(`Unknown shader data type: ${type}`); + } +}; + +export const getShaderDataTypeComponentCount = (type: ShaderDataType): number => { + switch (type) { + case ShaderDataType.FLOAT: + case ShaderDataType.INT: + case ShaderDataType.UINT: + case ShaderDataType.BOOL: + case ShaderDataType.SAMPLER_2D: + case ShaderDataType.SAMPLER_CUBE: + case ShaderDataType.SAMPLER_2D_ARRAY: + return 1; + + case ShaderDataType.VEC2: + case ShaderDataType.IVEC2: + case ShaderDataType.UVEC2: + case ShaderDataType.BVEC2: + return 2; + + case ShaderDataType.VEC3: + case ShaderDataType.IVEC3: + case ShaderDataType.UVEC3: + case ShaderDataType.BVEC3: + return 3; + + case ShaderDataType.VEC4: + case ShaderDataType.IVEC4: + case ShaderDataType.UVEC4: + case ShaderDataType.BVEC4: + case ShaderDataType.MAT2: + return 4; + + case ShaderDataType.MAT3: + return 9; + + case ShaderDataType.MAT4: + return 16; + + default: + throw new Error(`Unknown shader data type: ${type}`); + } +}; + +export const getWebGLType = (gl: WebGL2RenderingContext, type: ShaderDataType): number => { + switch (type) { + case ShaderDataType.FLOAT: + case ShaderDataType.VEC2: + case ShaderDataType.VEC3: + case ShaderDataType.VEC4: + case ShaderDataType.MAT2: + case ShaderDataType.MAT3: + case ShaderDataType.MAT4: + return gl.FLOAT; + + case ShaderDataType.INT: + case ShaderDataType.IVEC2: + case ShaderDataType.IVEC3: + case ShaderDataType.IVEC4: + return gl.INT; + + case ShaderDataType.UINT: + case ShaderDataType.UVEC2: + case ShaderDataType.UVEC3: + case ShaderDataType.UVEC4: + return gl.UNSIGNED_INT; + + case ShaderDataType.BOOL: + case ShaderDataType.BVEC2: + case ShaderDataType.BVEC3: + case ShaderDataType.BVEC4: + return gl.BOOL; + + case ShaderDataType.SAMPLER_2D: + return gl.SAMPLER_2D; + + case ShaderDataType.SAMPLER_CUBE: + return gl.SAMPLER_CUBE; + + case ShaderDataType.SAMPLER_2D_ARRAY: + return gl.SAMPLER_2D_ARRAY; + + default: + throw new Error(`Unknown shader data type: ${type}`); + } +}; + +export const MAX_VERTEX_ATTRIBUTES = 16; + +export const MAX_TEXTURE_UNITS = 32; + +export const MAX_UNIFORM_BLOCKS = 16; + +export const MAX_UNIFORM_BUFFER_SIZE = 64 * 1024; + +export const SHADER_CACHE_LIMITS = { + MAX_COMPILED_SHADERS: 256, + MAX_VARIANTS_PER_SHADER: 64, + MAX_CONFIGURATIONS: 128, + MAX_TOTAL_VARIANTS: 1024, + MAX_CACHE_SIZE_BYTES: 16 * 1024 * 1024, +} as const; + +export const VERTEX_SEMANTICS = { + POSITION: 'POSITION', + NORMAL: 'NORMAL', + TANGENT: 'TANGENT', + BITANGENT: 'BITANGENT', + COLOR: 'COLOR', + TEXCOORD: 'TEXCOORD', + BLENDINDICES: 'BLENDINDICES', + BLENDWEIGHT: 'BLENDWEIGHT', + INSTANCE_MATRIX: 'INSTANCE_MATRIX', + INSTANCE_COLOR: 'INSTANCE_COLOR', +} as const; + +export const UNIFORM_SEMANTICS = { + + MODEL_MATRIX: 'u_ModelMatrix', + VIEW_MATRIX: 'u_ViewMatrix', + PROJECTION_MATRIX: 'u_ProjectionMatrix', + MVP_MATRIX: 'u_MVPMatrix', + NORMAL_MATRIX: 'u_NormalMatrix', + + CAMERA_POSITION: 'u_CameraPosition', + CAMERA_DIRECTION: 'u_CameraDirection', + + TIME: 'u_Time', + DELTA_TIME: 'u_DeltaTime', + FRAME_COUNT: 'u_FrameCount', + + SCREEN_SIZE: 'u_ScreenSize', + TEXEL_SIZE: 'u_TexelSize', + + MAIN_TEXTURE: 'u_MainTexture', + NORMAL_MAP: 'u_NormalMap', + METALLIC_MAP: 'u_MetallicMap', + ROUGHNESS_MAP: 'u_RoughnessMap', + EMISSION_MAP: 'u_EmissionMap', + OCCLUSION_MAP: 'u_OcclusionMap', + + LIGHT_COUNT: 'u_LightCount', + AMBIENT_COLOR: 'u_AmbientColor', + +} as const; + +export const SHADER_KEYWORDS = { + + DIRECTIONAL_LIGHT: 'DIRECTIONAL_LIGHT', + POINT_LIGHT: 'POINT_LIGHT', + SPOT_LIGHT: 'SPOT_LIGHT', + + NORMAL_MAPPING: 'NORMAL_MAPPING', + METALLIC_WORKFLOW: 'METALLIC_WORKFLOW', + SPECULAR_WORKFLOW: 'SPECULAR_WORKFLOW', + EMISSION: 'EMISSION', + OCCLUSION: 'OCCLUSION', + + INSTANCING: 'INSTANCING', + SKINNING: 'SKINNING', + VERTEX_COLOR: 'VERTEX_COLOR', + FOG: 'FOG', + SHADOWS: 'SHADOWS', + + WEBGL2: 'WEBGL2', + MOBILE: 'MOBILE', + DESKTOP: 'DESKTOP', + +} as const; + +export const generateVersionDirective = (version: string = '300 es'): string => { + return `#version ${version}\n`; +}; + +export const generatePrecisionDirective = (precision: 'lowp' | 'mediump' | 'highp' = 'mediump'): string => { + return `precision ${precision} float;\n`; +}; + +export const generateDefines = (defines: Record): string => { + return Object.entries(defines) + .map(([key, value]) => { + if (typeof value === 'boolean') { + return value ? `#define ${key}\n` : ''; + } + return `#define ${key} ${value}\n`; + }) + .join(''); +}; + +export const generateIncludes = (includes: string[]): string => { + return includes.map(include => `#include "${include}"\n`).join(''); +}; + +export const hashShaderSource = (source: string): string => { + let hash = 0; + for (let i = 0; i < source.length; i++) { + const char = source.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return Math.abs(hash).toString(36); +}; + +export const generateVariantKey = ( + shaderName: string, + keywords: readonly string[], + defines: Record +): string => { + const sortedKeywords = [...keywords].sort().join('|'); + const sortedDefines = Object.entries(defines) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}:${value}`) + .join('|'); + + return `${shaderName}_${hashShaderSource(sortedKeywords)}_${hashShaderSource(sortedDefines)}`; +}; + +export const isValidShaderVariableName = (name: string): boolean => { + + return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name); +}; + +export const isValidStageCombo = (stages: ShaderStage[]): boolean => { + const stageSet = new Set(stages); + + if (stageSet.has(ShaderStage.COMPUTE)) { + return stages.length === 1; + } + + if (!stageSet.has(ShaderStage.VERTEX)) { + return false; + } + + const hasTessControl = stageSet.has(ShaderStage.TESSELLATION_CONTROL); + const hasTessEval = stageSet.has(ShaderStage.TESSELLATION_EVALUATION); + if (hasTessControl !== hasTessEval) { + return false; + } + + return true; +}; + +export const validateUniformNaming = (name: string): { valid: boolean; warnings: string[] } => { + const warnings: string[] = []; + + if (!isValidShaderVariableName(name)) { + return { valid: false, warnings: ['Invalid uniform name format'] }; + } + + if (!name.startsWith('u_')) { + warnings.push('Uniform names should start with "u_" prefix for consistency'); + } + + if (name.length > 64) { + warnings.push('Uniform name is very long, consider shortening'); + } + + return { valid: true, warnings }; +}; + +export const calculateAlignedOffset = (offset: number, alignment: number): number => { + return Math.ceil(offset / alignment) * alignment; +}; + +export const getShaderDataTypeAlignment = (type: ShaderDataType): number => { + switch (type) { + case ShaderDataType.FLOAT: + case ShaderDataType.INT: + case ShaderDataType.UINT: + case ShaderDataType.BOOL: + return 4; + + case ShaderDataType.VEC2: + case ShaderDataType.IVEC2: + case ShaderDataType.UVEC2: + case ShaderDataType.BVEC2: + return 8; + + case ShaderDataType.VEC3: + case ShaderDataType.IVEC3: + case ShaderDataType.UVEC3: + case ShaderDataType.BVEC3: + case ShaderDataType.VEC4: + case ShaderDataType.IVEC4: + case ShaderDataType.UVEC4: + case ShaderDataType.BVEC4: + case ShaderDataType.MAT2: + return 16; + + case ShaderDataType.MAT3: + case ShaderDataType.MAT4: + return 16; + + default: + return 4; + } +}; + +export const calculateUniformBufferLayout = (variables: Array<{ name: string; type: ShaderDataType; arraySize?: number }>): { + layout: Array<{ name: string; offset: number; size: number }>; + totalSize: number; +} => { + const layout: Array<{ name: string; offset: number; size: number }> = []; + let currentOffset = 0; + + for (const variable of variables) { + const elementSize = getShaderDataTypeSize(variable.type); + const alignment = getShaderDataTypeAlignment(variable.type); + const arraySize = variable.arraySize || 1; + + currentOffset = calculateAlignedOffset(currentOffset, alignment); + + const totalSize = elementSize * arraySize; + + layout.push({ + name: variable.name, + offset: currentOffset, + size: totalSize + }); + + currentOffset += totalSize; + } + + const totalSize = calculateAlignedOffset(currentOffset, 16); + + return { layout, totalSize }; +}; From a65d48cef61ca77ed19af96f9c6292cf3ece6977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= <56188619+hun756@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:56:57 +0300 Subject: [PATCH 12/13] Update packages/core/src/renderer/webgl2/mesh/geometry.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/core/src/renderer/webgl2/mesh/geometry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/renderer/webgl2/mesh/geometry.ts b/packages/core/src/renderer/webgl2/mesh/geometry.ts index bc0233c..4f4ab2e 100644 --- a/packages/core/src/renderer/webgl2/mesh/geometry.ts +++ b/packages/core/src/renderer/webgl2/mesh/geometry.ts @@ -111,7 +111,7 @@ export class WebGLGeometry implements IGeometry { throw new MeshError('Failed to create VAO', MeshErrorCode.VAO_CREATION_FAILED); } - const vaoId = `vao_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const vaoId = `vao_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; return { id: vaoId, From 282532f2479511ffba8d4244737336b90b898f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20=C5=9E=C3=BCkr=C3=BC=20Ekemen?= Date: Wed, 31 Dec 2025 04:26:00 +0300 Subject: [PATCH 13/13] Refactor IBindableTarget interface and update imports across WebGL2 modules --- packages/core/src/renderer/webgl2/buffer.ts | 6 +----- packages/core/src/renderer/webgl2/framebuffer.ts | 8 ++------ packages/core/src/renderer/webgl2/interfaces.ts | 4 ++++ packages/core/src/renderer/webgl2/mesh/interfaces.ts | 2 +- packages/core/src/renderer/webgl2/texture/interfaces.ts | 9 +-------- 5 files changed, 9 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/renderer/webgl2/interfaces.ts diff --git a/packages/core/src/renderer/webgl2/buffer.ts b/packages/core/src/renderer/webgl2/buffer.ts index bb58006..ba495a2 100644 --- a/packages/core/src/renderer/webgl2/buffer.ts +++ b/packages/core/src/renderer/webgl2/buffer.ts @@ -1,4 +1,5 @@ import { IDisposable } from '../../types'; +import type { IBindableTarget } from './interfaces'; type Nominal = T & { readonly __brand: K }; @@ -25,11 +26,6 @@ export type GLBufferUsage = export type BufferId = Nominal; -export interface IBindableTarget { - readonly bind: () => T; - readonly unbind: () => T; -} - export interface BufferOptions { readonly initialData?: BufferSource | null; readonly usage?: GLBufferUsage; diff --git a/packages/core/src/renderer/webgl2/framebuffer.ts b/packages/core/src/renderer/webgl2/framebuffer.ts index 70f246a..eef854c 100644 --- a/packages/core/src/renderer/webgl2/framebuffer.ts +++ b/packages/core/src/renderer/webgl2/framebuffer.ts @@ -1,10 +1,11 @@ import { IDisposable } from '../../types'; -import { ITexture, IBindableTarget } from './texture/interfaces'; +import type { IBindableTarget } from './interfaces'; type Nominal = T & { readonly __brand: K }; export type FramebufferId = Nominal; export type RenderbufferId = Nominal; +export type TextureId = Nominal; export type GLTextureTarget = | WebGL2RenderingContext['TEXTURE_2D'] @@ -136,11 +137,6 @@ export interface FramebufferOptions { readonly label?: string; } -export interface IBindableTarget { - readonly bind: () => T; - readonly unbind: () => T; -} - export interface ITexture extends IDisposable, IBindableTarget { readonly id: TextureId; readonly target: GLTextureTarget; diff --git a/packages/core/src/renderer/webgl2/interfaces.ts b/packages/core/src/renderer/webgl2/interfaces.ts new file mode 100644 index 0000000..b7173b0 --- /dev/null +++ b/packages/core/src/renderer/webgl2/interfaces.ts @@ -0,0 +1,4 @@ +export interface IBindableTarget { + bind(unit?: number): TReturn; + unbind(): TReturn; +} diff --git a/packages/core/src/renderer/webgl2/mesh/interfaces.ts b/packages/core/src/renderer/webgl2/mesh/interfaces.ts index 410f9ac..79a7ee2 100644 --- a/packages/core/src/renderer/webgl2/mesh/interfaces.ts +++ b/packages/core/src/renderer/webgl2/mesh/interfaces.ts @@ -1,6 +1,6 @@ import { Vec2, Vec3, Vec4, Mat4 } from '@axrone/numeric'; import { ByteBuffer } from '@axrone/utility'; -import { IBindableTarget } from '../texture/interfaces'; +import type { IBindableTarget } from '../interfaces'; export const enum VertexAttributeType { POSITION = 'POSITION', diff --git a/packages/core/src/renderer/webgl2/texture/interfaces.ts b/packages/core/src/renderer/webgl2/texture/interfaces.ts index 44109ed..bd7a186 100644 --- a/packages/core/src/renderer/webgl2/texture/interfaces.ts +++ b/packages/core/src/renderer/webgl2/texture/interfaces.ts @@ -1,5 +1,6 @@ import { Vec2, Vec3, Vec4 } from '@axrone/numeric'; import { ByteBuffer } from '@axrone/utility'; +import type { IBindableTarget } from '../interfaces'; export const enum TextureDimension { TEXTURE_1D = '1D', @@ -172,11 +173,6 @@ export interface ITextureSubresource { readonly depth?: number; } -export interface IBindableTarget { - bind(unit?: number): void; - unbind(): void; -} - export interface ITexture extends IBindableTarget { readonly id: string; readonly nativeHandle: WebGLTexture; @@ -206,9 +202,6 @@ export interface ITexture extends IBindableTarget { resize(width: number, height: number, depth?: number): void; clone(): ITexture; - bind(unit?: number): void; - unbind(): void; - dispose(): void; }