class dwWebAudio { // 0xffffffff = set // 0x00000000 = added // 0x00000001 = loaded / stopped // 0x00000002 = playing // 0x00000004 = looping //var dxAudioSourceState = []; constructor(dt) { this.dxAudioCtx = 0; this.dxSoundFiles = []; this.dxSoundFilesLoaded = 0; this.dxBuffer = []; this.dxAudioSource = []; this.dxAudioSourceState = []; //this.init(); } init() { this.dxAudioCtx = 0; this.dxSoundFiles = []; this.dxSoundFilesLoaded = 0; this.dxBuffer = []; this.dxAudioSource = []; this.dxAudioSourceState = []; } destroy() { return; var i = 0; for (i = 0; i < this.dxSoundFiles.length; i++) { this.dxBuffer.pop(); this.dxAudioSource[i].disconnect(); this.dxAudioSourceState[i] = -1; } this.dxSoundFilesLoaded = 0; if (this.dxAudioCtx) { this.dxAudioCtx.close(); } this.dxSoundFiles = []; this.dxSoundFilesLoaded = 0; this.dxBuffer = []; this.dxAudioSource = []; this.dxAudioSourceState = []; } addAudioFile(file) { this.dxSoundFiles.push(file); this.dxAudioSourceState.push(0); } async stopAudio(index) { this.dxAudioSource[index].stop(); this.dxAudioSourceState[index] = 1; } async loadAudio() { var i = 0; try { this.dxAudioCtx = new AudioContext(); for (i = 0; i < this.dxSoundFiles.length; i++) { const response = await fetch(this.dxSoundFiles[i]); var temp = await this.dxAudioCtx.decodeAudioData(await response.arrayBuffer()); this.dxBuffer.push(temp); var temp1 = this.dxAudioCtx.createBufferSource(); this.dxAudioSource.push(temp1); this.dxSoundFilesLoaded = this.dxSoundFilesLoaded + 1; this.dxAudioSourceState[i] = 1; } } catch (err) { alert('Unable to fetch the audio file ['+this.dxSoundFiles[i]+'|'+i+']. Error: ' + err.message); this.dxAudioSourceState[i] = 0; } } async playAudio(index, loop) { if ((this.dxAudioSourceState[index] & 4)) { return; } if (!this.dxAudioCtx) { await this.loadAudio(); } if (this.dxAudioSourceState[index] & 2) { this.dxAudioSource[index].stop(); this.dxAudioSourceState[index] = 1; } var temp1 = this.dxAudioCtx.createBufferSource(); this.dxAudioSource[index] = temp1; this.dxAudioSource[index].buffer = this.dxBuffer[index]; this.dxAudioSource[index].connect(this.dxAudioCtx.destination); this.dxAudioSource[index].loop = loop; this.dxAudioSource[index].loopStart = 0; this.dxAudioSource[index].loopEnd = 0; this.dxAudioSource[index].start(0); if (loop) { this.dxAudioSourceState[index] = 7; } else { this.dxAudioSourceState[index] = 3; } } } /* * Copyright 2021, GFXFundamentals. * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are * met: * * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above * copyright notice, this list of conditions and the following disclaimer * in the documentation and/or other materials provided with the * distribution. * * Neither the name of GFXFundamentals. nor the names of his * contributors may be used to endorse or promote products derived from * this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /** * Various 3d math functions. * * @module webgl-3d-math */ (function(root, factory) { // eslint-disable-line if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define([], factory); } else { // Browser globals root.m4 = factory(); } }(this, function() { "use strict"; /** * An array or typed array with 3 values. * @typedef {number[]|TypedArray} Vector3 * @memberOf module:webgl-3d-math */ /** * An array or typed array with 4 values. * @typedef {number[]|TypedArray} Vector4 * @memberOf module:webgl-3d-math */ /** * An array or typed array with 16 values. * @typedef {number[]|TypedArray} Matrix4 * @memberOf module:webgl-3d-math */ let MatType = Float32Array; /** * Sets the type this library creates for a Mat4 * @param {constructor} Ctor the constructor for the type. Either `Float32Array` or `Array` * @return {constructor} previous constructor for Mat4 */ function setDefaultType(Ctor) { const OldType = MatType; MatType = Ctor; return OldType; } /** * Takes two 4-by-4 matrices, a and b, and computes the product in the order * that pre-composes b with a. In other words, the matrix returned will * transform by b first and then a. Note this is subtly different from just * multiplying the matrices together. For given a and b, this function returns * the same object in both row-major and column-major mode. * @param {Matrix4} a A matrix. * @param {Matrix4} b A matrix. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided */ function multiply(a, b, dst) { dst = dst || new MatType(16); var b00 = b[0 * 4 + 0]; var b01 = b[0 * 4 + 1]; var b02 = b[0 * 4 + 2]; var b03 = b[0 * 4 + 3]; var b10 = b[1 * 4 + 0]; var b11 = b[1 * 4 + 1]; var b12 = b[1 * 4 + 2]; var b13 = b[1 * 4 + 3]; var b20 = b[2 * 4 + 0]; var b21 = b[2 * 4 + 1]; var b22 = b[2 * 4 + 2]; var b23 = b[2 * 4 + 3]; var b30 = b[3 * 4 + 0]; var b31 = b[3 * 4 + 1]; var b32 = b[3 * 4 + 2]; var b33 = b[3 * 4 + 3]; var a00 = a[0 * 4 + 0]; var a01 = a[0 * 4 + 1]; var a02 = a[0 * 4 + 2]; var a03 = a[0 * 4 + 3]; var a10 = a[1 * 4 + 0]; var a11 = a[1 * 4 + 1]; var a12 = a[1 * 4 + 2]; var a13 = a[1 * 4 + 3]; var a20 = a[2 * 4 + 0]; var a21 = a[2 * 4 + 1]; var a22 = a[2 * 4 + 2]; var a23 = a[2 * 4 + 3]; var a30 = a[3 * 4 + 0]; var a31 = a[3 * 4 + 1]; var a32 = a[3 * 4 + 2]; var a33 = a[3 * 4 + 3]; dst[ 0] = b00 * a00 + b01 * a10 + b02 * a20 + b03 * a30; dst[ 1] = b00 * a01 + b01 * a11 + b02 * a21 + b03 * a31; dst[ 2] = b00 * a02 + b01 * a12 + b02 * a22 + b03 * a32; dst[ 3] = b00 * a03 + b01 * a13 + b02 * a23 + b03 * a33; dst[ 4] = b10 * a00 + b11 * a10 + b12 * a20 + b13 * a30; dst[ 5] = b10 * a01 + b11 * a11 + b12 * a21 + b13 * a31; dst[ 6] = b10 * a02 + b11 * a12 + b12 * a22 + b13 * a32; dst[ 7] = b10 * a03 + b11 * a13 + b12 * a23 + b13 * a33; dst[ 8] = b20 * a00 + b21 * a10 + b22 * a20 + b23 * a30; dst[ 9] = b20 * a01 + b21 * a11 + b22 * a21 + b23 * a31; dst[10] = b20 * a02 + b21 * a12 + b22 * a22 + b23 * a32; dst[11] = b20 * a03 + b21 * a13 + b22 * a23 + b23 * a33; dst[12] = b30 * a00 + b31 * a10 + b32 * a20 + b33 * a30; dst[13] = b30 * a01 + b31 * a11 + b32 * a21 + b33 * a31; dst[14] = b30 * a02 + b31 * a12 + b32 * a22 + b33 * a32; dst[15] = b30 * a03 + b31 * a13 + b32 * a23 + b33 * a33; return dst; } /** * adds 2 vectors3s * @param {Vector3} a a * @param {Vector3} b b * @param {Vector3} dst optional vector3 to store result * @return {Vector3} dst or new Vector3 if not provided * @memberOf module:webgl-3d-math */ function addVectors(a, b, dst) { dst = dst || new MatType(3); dst[0] = a[0] + b[0]; dst[1] = a[1] + b[1]; dst[2] = a[2] + b[2]; return dst; } /** * subtracts 2 vectors3s * @param {Vector3} a a * @param {Vector3} b b * @param {Vector3} dst optional vector3 to store result * @return {Vector3} dst or new Vector3 if not provided * @memberOf module:webgl-3d-math */ function subtractVectors(a, b, dst) { dst = dst || new MatType(3); dst[0] = a[0] - b[0]; dst[1] = a[1] - b[1]; dst[2] = a[2] - b[2]; return dst; } /** * scale vectors3 * @param {Vector3} v vector * @param {Number} s scale * @param {Vector3} dst optional vector3 to store result * @return {Vector3} dst or new Vector3 if not provided * @memberOf module:webgl-3d-math */ function scaleVector(v, s, dst) { dst = dst || new MatType(3); dst[0] = v[0] * s; dst[1] = v[1] * s; dst[2] = v[2] * s; return dst; } /** * normalizes a vector. * @param {Vector3} v vector to normalize * @param {Vector3} dst optional vector3 to store result * @return {Vector3} dst or new Vector3 if not provided * @memberOf module:webgl-3d-math */ function normalize(v, dst) { dst = dst || new MatType(3); var length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); // make sure we don't divide by 0. if (length > 0.00001) { dst[0] = v[0] / length; dst[1] = v[1] / length; dst[2] = v[2] / length; } return dst; } /** * Computes the length of a vector * @param {Vector3} v vector to take length of * @return {number} length of vector */ function length(v) { return Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); } /** * Computes the length squared of a vector * @param {Vector3} v vector to take length of * @return {number} length sqaured of vector */ function lengthSq(v) { return v[0] * v[0] + v[1] * v[1] + v[2] * v[2]; } /** * Computes the cross product of 2 vectors3s * @param {Vector3} a a * @param {Vector3} b b * @param {Vector3} dst optional vector3 to store result * @return {Vector3} dst or new Vector3 if not provided * @memberOf module:webgl-3d-math */ function cross(a, b, dst) { dst = dst || new MatType(3); dst[0] = a[1] * b[2] - a[2] * b[1]; dst[1] = a[2] * b[0] - a[0] * b[2]; dst[2] = a[0] * b[1] - a[1] * b[0]; return dst; } /** * Computes the dot product of two vectors; assumes both vectors have * three entries. * @param {Vector3} a Operand vector. * @param {Vector3} b Operand vector. * @return {number} dot product * @memberOf module:webgl-3d-math */ function dot(a, b) { return (a[0] * b[0]) + (a[1] * b[1]) + (a[2] * b[2]); } /** * Computes the distance squared between 2 points * @param {Vector3} a * @param {Vector3} b * @return {number} distance squared between a and b */ function distanceSq(a, b) { const dx = a[0] - b[0]; const dy = a[1] - b[1]; const dz = a[2] - b[2]; return dx * dx + dy * dy + dz * dz; } /** * Computes the distance between 2 points * @param {Vector3} a * @param {Vector3} b * @return {number} distance between a and b */ function distance(a, b) { return Math.sqrt(distanceSq(a, b)); } /** * Makes an identity matrix. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function identity(dst) { dst = dst || new MatType(16); dst[ 0] = 1; dst[ 1] = 0; dst[ 2] = 0; dst[ 3] = 0; dst[ 4] = 0; dst[ 5] = 1; dst[ 6] = 0; dst[ 7] = 0; dst[ 8] = 0; dst[ 9] = 0; dst[10] = 1; dst[11] = 0; dst[12] = 0; dst[13] = 0; dst[14] = 0; dst[15] = 1; return dst; } /** * Transposes a matrix. * @param {Matrix4} m matrix to transpose. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function transpose(m, dst) { dst = dst || new MatType(16); dst[ 0] = m[0]; dst[ 1] = m[4]; dst[ 2] = m[8]; dst[ 3] = m[12]; dst[ 4] = m[1]; dst[ 5] = m[5]; dst[ 6] = m[9]; dst[ 7] = m[13]; dst[ 8] = m[2]; dst[ 9] = m[6]; dst[10] = m[10]; dst[11] = m[14]; dst[12] = m[3]; dst[13] = m[7]; dst[14] = m[11]; dst[15] = m[15]; return dst; } /** * Creates a lookAt matrix. * This is a world matrix for a camera. In other words it will transform * from the origin to a place and orientation in the world. For a view * matrix take the inverse of this. * @param {Vector3} cameraPosition position of the camera * @param {Vector3} target position of the target * @param {Vector3} up direction * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function lookAt(cameraPosition, target, up, dst) { dst = dst || new MatType(16); var zAxis = normalize( subtractVectors(cameraPosition, target)); var xAxis = normalize(cross(up, zAxis)); var yAxis = normalize(cross(zAxis, xAxis)); dst[ 0] = xAxis[0]; dst[ 1] = xAxis[1]; dst[ 2] = xAxis[2]; dst[ 3] = 0; dst[ 4] = yAxis[0]; dst[ 5] = yAxis[1]; dst[ 6] = yAxis[2]; dst[ 7] = 0; dst[ 8] = zAxis[0]; dst[ 9] = zAxis[1]; dst[10] = zAxis[2]; dst[11] = 0; dst[12] = cameraPosition[0]; dst[13] = cameraPosition[1]; dst[14] = cameraPosition[2]; dst[15] = 1; return dst; } /** * Computes a 4-by-4 perspective transformation matrix given the angular height * of the frustum, the aspect ratio, and the near and far clipping planes. The * arguments define a frustum extending in the negative z direction. The given * angle is the vertical angle of the frustum, and the horizontal angle is * determined to produce the given aspect ratio. The arguments near and far are * the distances to the near and far clipping planes. Note that near and far * are not z coordinates, but rather they are distances along the negative * z-axis. The matrix generated sends the viewing frustum to the unit box. * We assume a unit box extending from -1 to 1 in the x and y dimensions and * from -1 to 1 in the z dimension. * @param {number} fieldOfViewInRadians field of view in y axis. * @param {number} aspect aspect of viewport (width / height) * @param {number} near near Z clipping plane * @param {number} far far Z clipping plane * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function perspective(fieldOfViewInRadians, aspect, near, far, dst) { dst = dst || new MatType(16); var f = Math.tan(Math.PI * 0.5 - 0.5 * fieldOfViewInRadians); var rangeInv = 1.0 / (near - far); dst[ 0] = f / aspect; dst[ 1] = 0; dst[ 2] = 0; dst[ 3] = 0; dst[ 4] = 0; dst[ 5] = f; dst[ 6] = 0; dst[ 7] = 0; dst[ 8] = 0; dst[ 9] = 0; dst[10] = (near + far) * rangeInv; dst[11] = -1; dst[12] = 0; dst[13] = 0; dst[14] = near * far * rangeInv * 2; dst[15] = 0; return dst; } /** * Computes a 4-by-4 orthographic projection matrix given the coordinates of the * planes defining the axis-aligned, box-shaped viewing volume. The matrix * generated sends that box to the unit box. Note that although left and right * are x coordinates and bottom and top are y coordinates, near and far * are not z coordinates, but rather they are distances along the negative * z-axis. We assume a unit box extending from -1 to 1 in the x and y * dimensions and from -1 to 1 in the z dimension. * @param {number} left The x coordinate of the left plane of the box. * @param {number} right The x coordinate of the right plane of the box. * @param {number} bottom The y coordinate of the bottom plane of the box. * @param {number} top The y coordinate of the right plane of the box. * @param {number} near The negative z coordinate of the near plane of the box. * @param {number} far The negative z coordinate of the far plane of the box. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function orthographic(left, right, bottom, top, near, far, dst) { dst = dst || new MatType(16); dst[ 0] = 2 / (right - left); dst[ 1] = 0; dst[ 2] = 0; dst[ 3] = 0; dst[ 4] = 0; dst[ 5] = 2 / (top - bottom); dst[ 6] = 0; dst[ 7] = 0; dst[ 8] = 0; dst[ 9] = 0; dst[10] = 2 / (near - far); dst[11] = 0; dst[12] = (left + right) / (left - right); dst[13] = (bottom + top) / (bottom - top); dst[14] = (near + far) / (near - far); dst[15] = 1; return dst; } /** * Computes a 4-by-4 perspective transformation matrix given the left, right, * top, bottom, near and far clipping planes. The arguments define a frustum * extending in the negative z direction. The arguments near and far are the * distances to the near and far clipping planes. Note that near and far are not * z coordinates, but rather they are distances along the negative z-axis. The * matrix generated sends the viewing frustum to the unit box. We assume a unit * box extending from -1 to 1 in the x and y dimensions and from -1 to 1 in the z * dimension. * @param {number} left The x coordinate of the left plane of the box. * @param {number} right The x coordinate of the right plane of the box. * @param {number} bottom The y coordinate of the bottom plane of the box. * @param {number} top The y coordinate of the right plane of the box. * @param {number} near The negative z coordinate of the near plane of the box. * @param {number} far The negative z coordinate of the far plane of the box. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function frustum(left, right, bottom, top, near, far, dst) { dst = dst || new MatType(16); var dx = right - left; var dy = top - bottom; var dz = far - near; dst[ 0] = 2 * near / dx; dst[ 1] = 0; dst[ 2] = 0; dst[ 3] = 0; dst[ 4] = 0; dst[ 5] = 2 * near / dy; dst[ 6] = 0; dst[ 7] = 0; dst[ 8] = (left + right) / dx; dst[ 9] = (top + bottom) / dy; dst[10] = -(far + near) / dz; dst[11] = -1; dst[12] = 0; dst[13] = 0; dst[14] = -2 * near * far / dz; dst[15] = 0; return dst; } /** * Makes a translation matrix * @param {number} tx x translation. * @param {number} ty y translation. * @param {number} tz z translation. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function translation(tx, ty, tz, dst) { dst = dst || new MatType(16); dst[ 0] = 1; dst[ 1] = 0; dst[ 2] = 0; dst[ 3] = 0; dst[ 4] = 0; dst[ 5] = 1; dst[ 6] = 0; dst[ 7] = 0; dst[ 8] = 0; dst[ 9] = 0; dst[10] = 1; dst[11] = 0; dst[12] = tx; dst[13] = ty; dst[14] = tz; dst[15] = 1; return dst; } /** * Multiply by translation matrix. * @param {Matrix4} m matrix to multiply * @param {number} tx x translation. * @param {number} ty y translation. * @param {number} tz z translation. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function translate(m, tx, ty, tz, dst) { // This is the optimized version of // return multiply(m, translation(tx, ty, tz), dst); dst = dst || new MatType(16); var m00 = m[0]; var m01 = m[1]; var m02 = m[2]; var m03 = m[3]; var m10 = m[1 * 4 + 0]; var m11 = m[1 * 4 + 1]; var m12 = m[1 * 4 + 2]; var m13 = m[1 * 4 + 3]; var m20 = m[2 * 4 + 0]; var m21 = m[2 * 4 + 1]; var m22 = m[2 * 4 + 2]; var m23 = m[2 * 4 + 3]; var m30 = m[3 * 4 + 0]; var m31 = m[3 * 4 + 1]; var m32 = m[3 * 4 + 2]; var m33 = m[3 * 4 + 3]; if (m !== dst) { dst[ 0] = m00; dst[ 1] = m01; dst[ 2] = m02; dst[ 3] = m03; dst[ 4] = m10; dst[ 5] = m11; dst[ 6] = m12; dst[ 7] = m13; dst[ 8] = m20; dst[ 9] = m21; dst[10] = m22; dst[11] = m23; } dst[12] = m00 * tx + m10 * ty + m20 * tz + m30; dst[13] = m01 * tx + m11 * ty + m21 * tz + m31; dst[14] = m02 * tx + m12 * ty + m22 * tz + m32; dst[15] = m03 * tx + m13 * ty + m23 * tz + m33; return dst; } /** * Makes an x rotation matrix * @param {number} angleInRadians amount to rotate * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function xRotation(angleInRadians, dst) { dst = dst || new MatType(16); var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); dst[ 0] = 1; dst[ 1] = 0; dst[ 2] = 0; dst[ 3] = 0; dst[ 4] = 0; dst[ 5] = c; dst[ 6] = s; dst[ 7] = 0; dst[ 8] = 0; dst[ 9] = -s; dst[10] = c; dst[11] = 0; dst[12] = 0; dst[13] = 0; dst[14] = 0; dst[15] = 1; return dst; } /** * Multiply by an x rotation matrix * @param {Matrix4} m matrix to multiply * @param {number} angleInRadians amount to rotate * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function xRotate(m, angleInRadians, dst) { // this is the optimized version of // return multiply(m, xRotation(angleInRadians), dst); dst = dst || new MatType(16); var m10 = m[4]; var m11 = m[5]; var m12 = m[6]; var m13 = m[7]; var m20 = m[8]; var m21 = m[9]; var m22 = m[10]; var m23 = m[11]; var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); dst[4] = c * m10 + s * m20; dst[5] = c * m11 + s * m21; dst[6] = c * m12 + s * m22; dst[7] = c * m13 + s * m23; dst[8] = c * m20 - s * m10; dst[9] = c * m21 - s * m11; dst[10] = c * m22 - s * m12; dst[11] = c * m23 - s * m13; if (m !== dst) { dst[ 0] = m[ 0]; dst[ 1] = m[ 1]; dst[ 2] = m[ 2]; dst[ 3] = m[ 3]; dst[12] = m[12]; dst[13] = m[13]; dst[14] = m[14]; dst[15] = m[15]; } return dst; } /** * Makes an y rotation matrix * @param {number} angleInRadians amount to rotate * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function yRotation(angleInRadians, dst) { dst = dst || new MatType(16); var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); dst[ 0] = c; dst[ 1] = 0; dst[ 2] = -s; dst[ 3] = 0; dst[ 4] = 0; dst[ 5] = 1; dst[ 6] = 0; dst[ 7] = 0; dst[ 8] = s; dst[ 9] = 0; dst[10] = c; dst[11] = 0; dst[12] = 0; dst[13] = 0; dst[14] = 0; dst[15] = 1; return dst; } /** * Multiply by an y rotation matrix * @param {Matrix4} m matrix to multiply * @param {number} angleInRadians amount to rotate * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function yRotate(m, angleInRadians, dst) { // this is the optimized version of // return multiply(m, yRotation(angleInRadians), dst); dst = dst || new MatType(16); var m00 = m[0 * 4 + 0]; var m01 = m[0 * 4 + 1]; var m02 = m[0 * 4 + 2]; var m03 = m[0 * 4 + 3]; var m20 = m[2 * 4 + 0]; var m21 = m[2 * 4 + 1]; var m22 = m[2 * 4 + 2]; var m23 = m[2 * 4 + 3]; var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); dst[ 0] = c * m00 - s * m20; dst[ 1] = c * m01 - s * m21; dst[ 2] = c * m02 - s * m22; dst[ 3] = c * m03 - s * m23; dst[ 8] = c * m20 + s * m00; dst[ 9] = c * m21 + s * m01; dst[10] = c * m22 + s * m02; dst[11] = c * m23 + s * m03; if (m !== dst) { dst[ 4] = m[ 4]; dst[ 5] = m[ 5]; dst[ 6] = m[ 6]; dst[ 7] = m[ 7]; dst[12] = m[12]; dst[13] = m[13]; dst[14] = m[14]; dst[15] = m[15]; } return dst; } /** * Makes an z rotation matrix * @param {number} angleInRadians amount to rotate * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function zRotation(angleInRadians, dst) { dst = dst || new MatType(16); var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); dst[ 0] = c; dst[ 1] = s; dst[ 2] = 0; dst[ 3] = 0; dst[ 4] = -s; dst[ 5] = c; dst[ 6] = 0; dst[ 7] = 0; dst[ 8] = 0; dst[ 9] = 0; dst[10] = 1; dst[11] = 0; dst[12] = 0; dst[13] = 0; dst[14] = 0; dst[15] = 1; return dst; } /** * Multiply by an z rotation matrix * @param {Matrix4} m matrix to multiply * @param {number} angleInRadians amount to rotate * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function zRotate(m, angleInRadians, dst) { // This is the optimized version of // return multiply(m, zRotation(angleInRadians), dst); dst = dst || new MatType(16); var m00 = m[0 * 4 + 0]; var m01 = m[0 * 4 + 1]; var m02 = m[0 * 4 + 2]; var m03 = m[0 * 4 + 3]; var m10 = m[1 * 4 + 0]; var m11 = m[1 * 4 + 1]; var m12 = m[1 * 4 + 2]; var m13 = m[1 * 4 + 3]; var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); dst[ 0] = c * m00 + s * m10; dst[ 1] = c * m01 + s * m11; dst[ 2] = c * m02 + s * m12; dst[ 3] = c * m03 + s * m13; dst[ 4] = c * m10 - s * m00; dst[ 5] = c * m11 - s * m01; dst[ 6] = c * m12 - s * m02; dst[ 7] = c * m13 - s * m03; if (m !== dst) { dst[ 8] = m[ 8]; dst[ 9] = m[ 9]; dst[10] = m[10]; dst[11] = m[11]; dst[12] = m[12]; dst[13] = m[13]; dst[14] = m[14]; dst[15] = m[15]; } return dst; } /** * Makes an rotation matrix around an arbitrary axis * @param {Vector3} axis axis to rotate around * @param {number} angleInRadians amount to rotate * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function axisRotation(axis, angleInRadians, dst) { dst = dst || new MatType(16); var x = axis[0]; var y = axis[1]; var z = axis[2]; var n = Math.sqrt(x * x + y * y + z * z); x /= n; y /= n; z /= n; var xx = x * x; var yy = y * y; var zz = z * z; var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); var oneMinusCosine = 1 - c; dst[ 0] = xx + (1 - xx) * c; dst[ 1] = x * y * oneMinusCosine + z * s; dst[ 2] = x * z * oneMinusCosine - y * s; dst[ 3] = 0; dst[ 4] = x * y * oneMinusCosine - z * s; dst[ 5] = yy + (1 - yy) * c; dst[ 6] = y * z * oneMinusCosine + x * s; dst[ 7] = 0; dst[ 8] = x * z * oneMinusCosine + y * s; dst[ 9] = y * z * oneMinusCosine - x * s; dst[10] = zz + (1 - zz) * c; dst[11] = 0; dst[12] = 0; dst[13] = 0; dst[14] = 0; dst[15] = 1; return dst; } /** * Multiply by an axis rotation matrix * @param {Matrix4} m matrix to multiply * @param {Vector3} axis axis to rotate around * @param {number} angleInRadians amount to rotate * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function axisRotate(m, axis, angleInRadians, dst) { // This is the optimized version of // return multiply(m, axisRotation(axis, angleInRadians), dst); dst = dst || new MatType(16); var x = axis[0]; var y = axis[1]; var z = axis[2]; var n = Math.sqrt(x * x + y * y + z * z); x /= n; y /= n; z /= n; var xx = x * x; var yy = y * y; var zz = z * z; var c = Math.cos(angleInRadians); var s = Math.sin(angleInRadians); var oneMinusCosine = 1 - c; var r00 = xx + (1 - xx) * c; var r01 = x * y * oneMinusCosine + z * s; var r02 = x * z * oneMinusCosine - y * s; var r10 = x * y * oneMinusCosine - z * s; var r11 = yy + (1 - yy) * c; var r12 = y * z * oneMinusCosine + x * s; var r20 = x * z * oneMinusCosine + y * s; var r21 = y * z * oneMinusCosine - x * s; var r22 = zz + (1 - zz) * c; var m00 = m[0]; var m01 = m[1]; var m02 = m[2]; var m03 = m[3]; var m10 = m[4]; var m11 = m[5]; var m12 = m[6]; var m13 = m[7]; var m20 = m[8]; var m21 = m[9]; var m22 = m[10]; var m23 = m[11]; dst[ 0] = r00 * m00 + r01 * m10 + r02 * m20; dst[ 1] = r00 * m01 + r01 * m11 + r02 * m21; dst[ 2] = r00 * m02 + r01 * m12 + r02 * m22; dst[ 3] = r00 * m03 + r01 * m13 + r02 * m23; dst[ 4] = r10 * m00 + r11 * m10 + r12 * m20; dst[ 5] = r10 * m01 + r11 * m11 + r12 * m21; dst[ 6] = r10 * m02 + r11 * m12 + r12 * m22; dst[ 7] = r10 * m03 + r11 * m13 + r12 * m23; dst[ 8] = r20 * m00 + r21 * m10 + r22 * m20; dst[ 9] = r20 * m01 + r21 * m11 + r22 * m21; dst[10] = r20 * m02 + r21 * m12 + r22 * m22; dst[11] = r20 * m03 + r21 * m13 + r22 * m23; if (m !== dst) { dst[12] = m[12]; dst[13] = m[13]; dst[14] = m[14]; dst[15] = m[15]; } return dst; } /** * Makes a scale matrix * @param {number} sx x scale. * @param {number} sy y scale. * @param {number} sz z scale. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function scaling(sx, sy, sz, dst) { dst = dst || new MatType(16); dst[ 0] = sx; dst[ 1] = 0; dst[ 2] = 0; dst[ 3] = 0; dst[ 4] = 0; dst[ 5] = sy; dst[ 6] = 0; dst[ 7] = 0; dst[ 8] = 0; dst[ 9] = 0; dst[10] = sz; dst[11] = 0; dst[12] = 0; dst[13] = 0; dst[14] = 0; dst[15] = 1; return dst; } /** * Multiply by a scaling matrix * @param {Matrix4} m matrix to multiply * @param {number} sx x scale. * @param {number} sy y scale. * @param {number} sz z scale. * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function scale(m, sx, sy, sz, dst) { // This is the optimized version of // return multiply(m, scaling(sx, sy, sz), dst); dst = dst || new MatType(16); dst[ 0] = sx * m[0 * 4 + 0]; dst[ 1] = sx * m[0 * 4 + 1]; dst[ 2] = sx * m[0 * 4 + 2]; dst[ 3] = sx * m[0 * 4 + 3]; dst[ 4] = sy * m[1 * 4 + 0]; dst[ 5] = sy * m[1 * 4 + 1]; dst[ 6] = sy * m[1 * 4 + 2]; dst[ 7] = sy * m[1 * 4 + 3]; dst[ 8] = sz * m[2 * 4 + 0]; dst[ 9] = sz * m[2 * 4 + 1]; dst[10] = sz * m[2 * 4 + 2]; dst[11] = sz * m[2 * 4 + 3]; if (m !== dst) { dst[12] = m[12]; dst[13] = m[13]; dst[14] = m[14]; dst[15] = m[15]; } return dst; } /** * creates a matrix from translation, quaternion, scale * @param {Number[]} translation [x, y, z] translation * @param {Number[]} quaternion [x, y, z, z] quaternion rotation * @param {Number[]} scale [x, y, z] scale * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided */ function compose(translation, quaternion, scale, dst) { dst = dst || new MatType(16); const x = quaternion[0]; const y = quaternion[1]; const z = quaternion[2]; const w = quaternion[3]; const x2 = x + x; const y2 = y + y; const z2 = z + z; const xx = x * x2; const xy = x * y2; const xz = x * z2; const yy = y * y2; const yz = y * z2; const zz = z * z2; const wx = w * x2; const wy = w * y2; const wz = w * z2; const sx = scale[0]; const sy = scale[1]; const sz = scale[2]; dst[0] = (1 - (yy + zz)) * sx; dst[1] = (xy + wz) * sx; dst[2] = (xz - wy) * sx; dst[3] = 0; dst[4] = (xy - wz) * sy; dst[5] = (1 - (xx + zz)) * sy; dst[6] = (yz + wx) * sy; dst[7] = 0; dst[ 8] = (xz + wy) * sz; dst[ 9] = (yz - wx) * sz; dst[10] = (1 - (xx + yy)) * sz; dst[11] = 0; dst[12] = translation[0]; dst[13] = translation[1]; dst[14] = translation[2]; dst[15] = 1; return dst; } function quatFromRotationMatrix(m, dst) { // http://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion/index.htm // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled) const m11 = m[0]; const m12 = m[4]; const m13 = m[8]; const m21 = m[1]; const m22 = m[5]; const m23 = m[9]; const m31 = m[2]; const m32 = m[6]; const m33 = m[10]; const trace = m11 + m22 + m33; if (trace > 0) { const s = 0.5 / Math.sqrt(trace + 1); dst[3] = 0.25 / s; dst[0] = (m32 - m23) * s; dst[1] = (m13 - m31) * s; dst[2] = (m21 - m12) * s; } else if (m11 > m22 && m11 > m33) { const s = 2 * Math.sqrt(1 + m11 - m22 - m33); dst[3] = (m32 - m23) / s; dst[0] = 0.25 * s; dst[1] = (m12 + m21) / s; dst[2] = (m13 + m31) / s; } else if (m22 > m33) { const s = 2 * Math.sqrt(1 + m22 - m11 - m33); dst[3] = (m13 - m31) / s; dst[0] = (m12 + m21) / s; dst[1] = 0.25 * s; dst[2] = (m23 + m32) / s; } else { const s = 2 * Math.sqrt(1 + m33 - m11 - m22); dst[3] = (m21 - m12) / s; dst[0] = (m13 + m31) / s; dst[1] = (m23 + m32) / s; dst[2] = 0.25 * s; } } function decompose(mat, translation, quaternion, scale) { let sx = m4.length(mat.slice(0, 3)); const sy = m4.length(mat.slice(4, 7)); const sz = m4.length(mat.slice(8, 11)); // if determinate is negative, we need to invert one scale const det = determinate(mat); if (det < 0) { sx = -sx; } translation[0] = mat[12]; translation[1] = mat[13]; translation[2] = mat[14]; // scale the rotation part const matrix = m4.copy(mat); const invSX = 1 / sx; const invSY = 1 / sy; const invSZ = 1 / sz; matrix[0] *= invSX; matrix[1] *= invSX; matrix[2] *= invSX; matrix[4] *= invSY; matrix[5] *= invSY; matrix[6] *= invSY; matrix[8] *= invSZ; matrix[9] *= invSZ; matrix[10] *= invSZ; quatFromRotationMatrix(matrix, quaternion); scale[0] = sx; scale[1] = sy; scale[2] = sz; } function determinate(m) { var m00 = m[0 * 4 + 0]; var m01 = m[0 * 4 + 1]; var m02 = m[0 * 4 + 2]; var m03 = m[0 * 4 + 3]; var m10 = m[1 * 4 + 0]; var m11 = m[1 * 4 + 1]; var m12 = m[1 * 4 + 2]; var m13 = m[1 * 4 + 3]; var m20 = m[2 * 4 + 0]; var m21 = m[2 * 4 + 1]; var m22 = m[2 * 4 + 2]; var m23 = m[2 * 4 + 3]; var m30 = m[3 * 4 + 0]; var m31 = m[3 * 4 + 1]; var m32 = m[3 * 4 + 2]; var m33 = m[3 * 4 + 3]; var tmp_0 = m22 * m33; var tmp_1 = m32 * m23; var tmp_2 = m12 * m33; var tmp_3 = m32 * m13; var tmp_4 = m12 * m23; var tmp_5 = m22 * m13; var tmp_6 = m02 * m33; var tmp_7 = m32 * m03; var tmp_8 = m02 * m23; var tmp_9 = m22 * m03; var tmp_10 = m02 * m13; var tmp_11 = m12 * m03; var t0 = (tmp_0 * m11 + tmp_3 * m21 + tmp_4 * m31) - (tmp_1 * m11 + tmp_2 * m21 + tmp_5 * m31); var t1 = (tmp_1 * m01 + tmp_6 * m21 + tmp_9 * m31) - (tmp_0 * m01 + tmp_7 * m21 + tmp_8 * m31); var t2 = (tmp_2 * m01 + tmp_7 * m11 + tmp_10 * m31) - (tmp_3 * m01 + tmp_6 * m11 + tmp_11 * m31); var t3 = (tmp_5 * m01 + tmp_8 * m11 + tmp_11 * m21) - (tmp_4 * m01 + tmp_9 * m11 + tmp_10 * m21); return 1.0 / (m00 * t0 + m10 * t1 + m20 * t2 + m30 * t3); } /** * Computes the inverse of a matrix. * @param {Matrix4} m matrix to compute inverse of * @param {Matrix4} [dst] optional matrix to store result * @return {Matrix4} dst or a new matrix if none provided * @memberOf module:webgl-3d-math */ function inverse(m, dst) { dst = dst || new MatType(16); var m00 = m[0 * 4 + 0]; var m01 = m[0 * 4 + 1]; var m02 = m[0 * 4 + 2]; var m03 = m[0 * 4 + 3]; var m10 = m[1 * 4 + 0]; var m11 = m[1 * 4 + 1]; var m12 = m[1 * 4 + 2]; var m13 = m[1 * 4 + 3]; var m20 = m[2 * 4 + 0]; var m21 = m[2 * 4 + 1]; var m22 = m[2 * 4 + 2]; var m23 = m[2 * 4 + 3]; var m30 = m[3 * 4 + 0]; var m31 = m[3 * 4 + 1]; var m32 = m[3 * 4 + 2]; var m33 = m[3 * 4 + 3]; var tmp_0 = m22 * m33; var tmp_1 = m32 * m23; var tmp_2 = m12 * m33; var tmp_3 = m32 * m13; var tmp_4 = m12 * m23; var tmp_5 = m22 * m13; var tmp_6 = m02 * m33; var tmp_7 = m32 * m03; var tmp_8 = m02 * m23; var tmp_9 = m22 * m03; var tmp_10 = m02 * m13; var tmp_11 = m12 * m03; var tmp_12 = m20 * m31; var tmp_13 = m30 * m21; var tmp_14 = m10 * m31; var tmp_15 = m30 * m11; var tmp_16 = m10 * m21; var tmp_17 = m20 * m11; var tmp_18 = m00 * m31; var tmp_19 = m30 * m01; var tmp_20 = m00 * m21; var tmp_21 = m20 * m01; var tmp_22 = m00 * m11; var tmp_23 = m10 * m01; var t0 = (tmp_0 * m11 + tmp_3 * m21 + tmp_4 * m31) - (tmp_1 * m11 + tmp_2 * m21 + tmp_5 * m31); var t1 = (tmp_1 * m01 + tmp_6 * m21 + tmp_9 * m31) - (tmp_0 * m01 + tmp_7 * m21 + tmp_8 * m31); var t2 = (tmp_2 * m01 + tmp_7 * m11 + tmp_10 * m31) - (tmp_3 * m01 + tmp_6 * m11 + tmp_11 * m31); var t3 = (tmp_5 * m01 + tmp_8 * m11 + tmp_11 * m21) - (tmp_4 * m01 + tmp_9 * m11 + tmp_10 * m21); var d = 1.0 / (m00 * t0 + m10 * t1 + m20 * t2 + m30 * t3); dst[0] = d * t0; dst[1] = d * t1; dst[2] = d * t2; dst[3] = d * t3; dst[4] = d * ((tmp_1 * m10 + tmp_2 * m20 + tmp_5 * m30) - (tmp_0 * m10 + tmp_3 * m20 + tmp_4 * m30)); dst[5] = d * ((tmp_0 * m00 + tmp_7 * m20 + tmp_8 * m30) - (tmp_1 * m00 + tmp_6 * m20 + tmp_9 * m30)); dst[6] = d * ((tmp_3 * m00 + tmp_6 * m10 + tmp_11 * m30) - (tmp_2 * m00 + tmp_7 * m10 + tmp_10 * m30)); dst[7] = d * ((tmp_4 * m00 + tmp_9 * m10 + tmp_10 * m20) - (tmp_5 * m00 + tmp_8 * m10 + tmp_11 * m20)); dst[8] = d * ((tmp_12 * m13 + tmp_15 * m23 + tmp_16 * m33) - (tmp_13 * m13 + tmp_14 * m23 + tmp_17 * m33)); dst[9] = d * ((tmp_13 * m03 + tmp_18 * m23 + tmp_21 * m33) - (tmp_12 * m03 + tmp_19 * m23 + tmp_20 * m33)); dst[10] = d * ((tmp_14 * m03 + tmp_19 * m13 + tmp_22 * m33) - (tmp_15 * m03 + tmp_18 * m13 + tmp_23 * m33)); dst[11] = d * ((tmp_17 * m03 + tmp_20 * m13 + tmp_23 * m23) - (tmp_16 * m03 + tmp_21 * m13 + tmp_22 * m23)); dst[12] = d * ((tmp_14 * m22 + tmp_17 * m32 + tmp_13 * m12) - (tmp_16 * m32 + tmp_12 * m12 + tmp_15 * m22)); dst[13] = d * ((tmp_20 * m32 + tmp_12 * m02 + tmp_19 * m22) - (tmp_18 * m22 + tmp_21 * m32 + tmp_13 * m02)); dst[14] = d * ((tmp_18 * m12 + tmp_23 * m32 + tmp_15 * m02) - (tmp_22 * m32 + tmp_14 * m02 + tmp_19 * m12)); dst[15] = d * ((tmp_22 * m22 + tmp_16 * m02 + tmp_21 * m12) - (tmp_20 * m12 + tmp_23 * m22 + tmp_17 * m02)); return dst; } /** * Takes a matrix and a vector with 4 entries, transforms that vector by * the matrix, and returns the result as a vector with 4 entries. * @param {Matrix4} m The matrix. * @param {Vector4} v The point in homogenous coordinates. * @param {Vector4} dst optional vector4 to store result * @return {Vector4} dst or new Vector4 if not provided * @memberOf module:webgl-3d-math */ function transformVector(m, v, dst) { dst = dst || new MatType(4); for (var i = 0; i < 4; ++i) { dst[i] = 0.0; for (var j = 0; j < 4; ++j) { dst[i] += v[j] * m[j * 4 + i]; } } return dst; } /** * Takes a 4-by-4 matrix and a vector with 3 entries, * interprets the vector as a point, transforms that point by the matrix, and * returns the result as a vector with 3 entries. * @param {Matrix4} m The matrix. * @param {Vector3} v The point. * @param {Vector4} dst optional vector4 to store result * @return {Vector4} dst or new Vector4 if not provided * @memberOf module:webgl-3d-math */ function transformPoint(m, v, dst) { dst = dst || new MatType(3); var v0 = v[0]; var v1 = v[1]; var v2 = v[2]; var d = v0 * m[0 * 4 + 3] + v1 * m[1 * 4 + 3] + v2 * m[2 * 4 + 3] + m[3 * 4 + 3]; dst[0] = (v0 * m[0 * 4 + 0] + v1 * m[1 * 4 + 0] + v2 * m[2 * 4 + 0] + m[3 * 4 + 0]) / d; dst[1] = (v0 * m[0 * 4 + 1] + v1 * m[1 * 4 + 1] + v2 * m[2 * 4 + 1] + m[3 * 4 + 1]) / d; dst[2] = (v0 * m[0 * 4 + 2] + v1 * m[1 * 4 + 2] + v2 * m[2 * 4 + 2] + m[3 * 4 + 2]) / d; return dst; } /** * Takes a 4-by-4 matrix and a vector with 3 entries, interprets the vector as a * direction, transforms that direction by the matrix, and returns the result; * assumes the transformation of 3-dimensional space represented by the matrix * is parallel-preserving, i.e. any combination of rotation, scaling and * translation, but not a perspective distortion. Returns a vector with 3 * entries. * @param {Matrix4} m The matrix. * @param {Vector3} v The direction. * @param {Vector4} dst optional vector4 to store result * @return {Vector4} dst or new Vector4 if not provided * @memberOf module:webgl-3d-math */ function transformDirection(m, v, dst) { dst = dst || new MatType(3); var v0 = v[0]; var v1 = v[1]; var v2 = v[2]; dst[0] = v0 * m[0 * 4 + 0] + v1 * m[1 * 4 + 0] + v2 * m[2 * 4 + 0]; dst[1] = v0 * m[0 * 4 + 1] + v1 * m[1 * 4 + 1] + v2 * m[2 * 4 + 1]; dst[2] = v0 * m[0 * 4 + 2] + v1 * m[1 * 4 + 2] + v2 * m[2 * 4 + 2]; return dst; } /** * Takes a 4-by-4 matrix m and a vector v with 3 entries, interprets the vector * as a normal to a surface, and computes a vector which is normal upon * transforming that surface by the matrix. The effect of this function is the * same as transforming v (as a direction) by the inverse-transpose of m. This * function assumes the transformation of 3-dimensional space represented by the * matrix is parallel-preserving, i.e. any combination of rotation, scaling and * translation, but not a perspective distortion. Returns a vector with 3 * entries. * @param {Matrix4} m The matrix. * @param {Vector3} v The normal. * @param {Vector3} [dst] The direction. * @return {Vector3} The transformed direction. * @memberOf module:webgl-3d-math */ function transformNormal(m, v, dst) { dst = dst || new MatType(3); var mi = inverse(m); var v0 = v[0]; var v1 = v[1]; var v2 = v[2]; dst[0] = v0 * mi[0 * 4 + 0] + v1 * mi[0 * 4 + 1] + v2 * mi[0 * 4 + 2]; dst[1] = v0 * mi[1 * 4 + 0] + v1 * mi[1 * 4 + 1] + v2 * mi[1 * 4 + 2]; dst[2] = v0 * mi[2 * 4 + 0] + v1 * mi[2 * 4 + 1] + v2 * mi[2 * 4 + 2]; return dst; } function copy(src, dst) { dst = dst || new MatType(16); dst[ 0] = src[ 0]; dst[ 1] = src[ 1]; dst[ 2] = src[ 2]; dst[ 3] = src[ 3]; dst[ 4] = src[ 4]; dst[ 5] = src[ 5]; dst[ 6] = src[ 6]; dst[ 7] = src[ 7]; dst[ 8] = src[ 8]; dst[ 9] = src[ 9]; dst[10] = src[10]; dst[11] = src[11]; dst[12] = src[12]; dst[13] = src[13]; dst[14] = src[14]; dst[15] = src[15]; return dst; } return { copy: copy, lookAt: lookAt, addVectors: addVectors, subtractVectors: subtractVectors, scaleVector: scaleVector, distance: distance, distanceSq: distanceSq, normalize: normalize, compose: compose, cross: cross, decompose: decompose, dot: dot, identity: identity, transpose: transpose, length: length, lengthSq: lengthSq, orthographic: orthographic, frustum: frustum, perspective: perspective, translation: translation, translate: translate, xRotation: xRotation, yRotation: yRotation, zRotation: zRotation, xRotate: xRotate, yRotate: yRotate, zRotate: zRotate, axisRotation: axisRotation, axisRotate: axisRotate, scaling: scaling, scale: scale, multiply: multiply, inverse: inverse, transformVector: transformVector, transformPoint: transformPoint, transformDirection: transformDirection, transformNormal: transformNormal, setDefaultType: setDefaultType, }; })); /*! * @license twgl.js 4.17.0 Copyright (c) 2015, Gregg Tavares All Rights Reserved. * Available via the MIT license. * see: http://github.com/greggman/twgl.js for details */ !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.twgl=t():e.twgl=t()}("undefined"!=typeof self?self:this,(function(){return function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}return r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="",r(r.s=8)}([function(e,t,r){"use strict";t.__esModule=!0,t.copyExistingProperties=function(e,t){Object.keys(t).forEach((function(r){t.hasOwnProperty(r)&&e.hasOwnProperty(r)&&(t[r]=e[r])}))},t.copyNamedProperties=function(e,t,r){e.forEach((function(e){var n=t[e];void 0!==n&&(r[e]=n)}))},t.error=function(){var e;(e=console).error.apply(e,arguments)},t.warn=function(){var e;(e=console).warn.apply(e,arguments)},t.isBuffer=function(e,t){return"undefined"!=typeof WebGLBuffer&&t instanceof WebGLBuffer},t.isRenderbuffer=function(e,t){return"undefined"!=typeof WebGLRenderbuffer&&t instanceof WebGLRenderbuffer},t.isShader=function(e,t){return"undefined"!=typeof WebGLShader&&t instanceof WebGLShader},t.isTexture=function(e,t){return"undefined"!=typeof WebGLTexture&&t instanceof WebGLTexture},t.isSampler=function(e,t){return"undefined"!=typeof WebGLSampler&&t instanceof WebGLSampler}},function(e,t,r){"use strict";t.__esModule=!0,t.getGLTypeForTypedArray=function(e){if(e instanceof Int8Array)return n;if(e instanceof Uint8Array)return o;if(e instanceof Uint8ClampedArray)return o;if(e instanceof Int16Array)return u;if(e instanceof Uint16Array)return i;if(e instanceof Int32Array)return a;if(e instanceof Uint32Array)return f;if(e instanceof Float32Array)return c;throw new Error("unsupported typed array type")},t.getGLTypeForTypedArrayType=function(e){if(e===Int8Array)return n;if(e===Uint8Array)return o;if(e===Uint8ClampedArray)return o;if(e===Int16Array)return u;if(e===Uint16Array)return i;if(e===Int32Array)return a;if(e===Uint32Array)return f;if(e===Float32Array)return c;throw new Error("unsupported typed array type")},t.getTypedArrayTypeForGLType=function(e){var t=l[e];if(!t)throw new Error("unknown gl type");return t},t.isArrayBuffer=void 0;var n=5120,o=5121,u=5122,i=5123,a=5124,f=5125,c=5126,l={},s=l;s[n]=Int8Array,s[5121]=Uint8Array,s[5122]=Int16Array,s[5123]=Uint16Array,s[a]=Int32Array,s[5125]=Uint32Array,s[5126]=Float32Array,s[32819]=Uint16Array,s[32820]=Uint16Array,s[33635]=Uint16Array,s[5131]=Uint16Array,s[33640]=Uint32Array,s[35899]=Uint32Array,s[35902]=Uint32Array,s[36269]=Uint32Array,s[34042]=Uint32Array;var y="undefined"!=typeof SharedArrayBuffer?function(e){return e&&e.buffer&&(e.buffer instanceof ArrayBuffer||e.buffer instanceof SharedArrayBuffer)}:function(e){return e&&e.buffer&&e.buffer instanceof ArrayBuffer};t.isArrayBuffer=y},function(e,t,r){"use strict";t.__esModule=!0,t.add=function(e,t,r){return(r=r||new n(3))[0]=e[0]+t[0],r[1]=e[1]+t[1],r[2]=e[2]+t[2],r},t.copy=function(e,t){return(t=t||new n(3))[0]=e[0],t[1]=e[1],t[2]=e[2],t},t.create=function(e,t,r){var o=new n(3);e&&(o[0]=e);t&&(o[1]=t);r&&(o[2]=r);return o},t.cross=function(e,t,r){r=r||new n(3);var o=e[2]*t[0]-e[0]*t[2],u=e[0]*t[1]-e[1]*t[0];return r[0]=e[1]*t[2]-e[2]*t[1],r[1]=o,r[2]=u,r},t.distance=function(e,t){var r=e[0]-t[0],n=e[1]-t[1],o=e[2]-t[2];return Math.sqrt(r*r+n*n+o*o)},t.distanceSq=function(e,t){var r=e[0]-t[0],n=e[1]-t[1],o=e[2]-t[2];return r*r+n*n+o*o},t.divide=function(e,t,r){return(r=r||new n(3))[0]=e[0]/t[0],r[1]=e[1]/t[1],r[2]=e[2]/t[2],r},t.divScalar=function(e,t,r){return(r=r||new n(3))[0]=e[0]/t,r[1]=e[1]/t,r[2]=e[2]/t,r},t.dot=function(e,t){return e[0]*t[0]+e[1]*t[1]+e[2]*t[2]},t.lerp=function(e,t,r,o){return(o=o||new n(3))[0]=e[0]+r*(t[0]-e[0]),o[1]=e[1]+r*(t[1]-e[1]),o[2]=e[2]+r*(t[2]-e[2]),o},t.lerpV=function(e,t,r,o){return(o=o||new n(3))[0]=e[0]+r[0]*(t[0]-e[0]),o[1]=e[1]+r[1]*(t[1]-e[1]),o[2]=e[2]+r[2]*(t[2]-e[2]),o},t.length=function(e){return Math.sqrt(e[0]*e[0]+e[1]*e[1]+e[2]*e[2])},t.lengthSq=function(e){return e[0]*e[0]+e[1]*e[1]+e[2]*e[2]},t.max=function(e,t,r){return(r=r||new n(3))[0]=Math.max(e[0],t[0]),r[1]=Math.max(e[1],t[1]),r[2]=Math.max(e[2],t[2]),r},t.min=function(e,t,r){return(r=r||new n(3))[0]=Math.min(e[0],t[0]),r[1]=Math.min(e[1],t[1]),r[2]=Math.min(e[2],t[2]),r},t.mulScalar=function(e,t,r){return(r=r||new n(3))[0]=e[0]*t,r[1]=e[1]*t,r[2]=e[2]*t,r},t.multiply=function(e,t,r){return(r=r||new n(3))[0]=e[0]*t[0],r[1]=e[1]*t[1],r[2]=e[2]*t[2],r},t.negate=function(e,t){return(t=t||new n(3))[0]=-e[0],t[1]=-e[1],t[2]=-e[2],t},t.normalize=function(e,t){t=t||new n(3);var r=e[0]*e[0]+e[1]*e[1]+e[2]*e[2],o=Math.sqrt(r);o>1e-5?(t[0]=e[0]/o,t[1]=e[1]/o,t[2]=e[2]/o):(t[0]=0,t[1]=0,t[2]=0);return t},t.setDefaultType=function(e){var t=n;return n=e,t},t.subtract=function(e,t,r){return(r=r||new n(3))[0]=e[0]-t[0],r[1]=e[1]-t[1],r[2]=e[2]-t[2],r};var n=Float32Array},function(e,t,r){"use strict";t.__esModule=!0,t.isWebGL1=function(e){return!e.texStorage2D},t.isWebGL2=function(e){return!!e.texStorage2D},t.glEnumToString=void 0;var n,o,u=(n={},o={},function(e,t){return function(e){var t=e.constructor.name;if(!n[t]){for(var r in e)if("number"==typeof e[r]){var u=o[e[r]];o[e[r]]=u?"".concat(u," | ").concat(r):r}n[t]=!0}}(e),o[t]||"0x"+t.toString(16)});t.glEnumToString=u},function(e,t,r){"use strict";function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}t.__esModule=!0,t.createAttributeSetters=oe,t.createProgram=H,t.createProgramFromScripts=function(e,t,r,n,o){for(var u=G(r,n,o),i=[],a=0;ae.length)&&(t=e.length);for(var r=0,n=new Array(t);r1&&void 0!==arguments[1]?arguments[1]:"",r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:0,n=f(t.matchAll(W)),o=new Map(n.map((function(e,r){var o=parseInt(e[1]),u=n[r+1],i=u?u.index:t.length;return[o-1,t.substring(e.index,i)]})));return e.split("\n").map((function(e,t){var n=o.get(t);return"".concat(t+1+r,": ").concat(e).concat(n?"\n\n^^^ ".concat(n):"")})).join("\n")}var B=/^[ \t]*\n/;function L(e,t,r,n){var u=n||l,i=e.createShader(r),a=0;if(B.test(t)&&(a=1,t=t.replace(B,"")),e.shaderSource(i,t),e.compileShader(i),!e.getShaderParameter(i,35713)){var f=e.getShaderInfoLog(i);return u("".concat(I(t,f,a),"\nError compiling ").concat(o.glEnumToString(e,r),": ").concat(f)),e.deleteShader(i),null}return i}function G(e,t,r){var n,o;if("function"==typeof t&&(r=t,t=void 0),"function"==typeof e)r=e,e=void 0;else if(e&&!Array.isArray(e)){if(e.errorCallback)return e;var u=e;r=u.errorCallback,e=u.attribLocations,n=u.transformFeedbackVaryings,o=u.transformFeedbackMode}var i={errorCallback:r||l,transformFeedbackVaryings:n,transformFeedbackMode:o};if(e){var a={};Array.isArray(e)?e.forEach((function(e,r){a[e]=t?t[r]:r})):a=e,i.attribLocations=a}return i}var X=["VERTEX_SHADER","FRAGMENT_SHADER"];function D(e,t){return t.indexOf("frag")>=0?35632:t.indexOf("vert")>=0?35633:void 0}function N(e,t){t.forEach((function(t){e.deleteShader(t)}))}function H(e,t,r,n,i){for(var a=G(r,n,i),f=[],c=[],l=0;l0)throw new Error("numComponents ".concat(u," not correct for length ").concat(o));return i}(t);return r},t.createBufferFromArray=F,t.createBufferFromTypedArray=s,t.createBufferInfoFromArrays=function(e,t,r){var n=w(e,t),u=Object.assign({},r||{});u.attribs=Object.assign({},r?r.attribs:{},n);var i=t.indices;if(i){var a=x(i,"indices");u.indices=s(e,a,34963),u.numElements=a.length,u.elementType=o.getGLTypeForTypedArray(a)}else u.numElements||(u.numElements=function(e,t){var r,n;for(n=0;n0)throw new Error("Can not guess numComponents for attribute '".concat(e,"'. Tried ").concat(r," but ").concat(t," values is not evenly divisible by ").concat(r,". You should specify it."));return r}function d(e,t){return e.numComponents||e.size||p(t,v(e).length)}function x(e,t){if(o.isArrayBuffer(e))return e;if(o.isArrayBuffer(e.data))return e.data;Array.isArray(e)&&(e={data:e});var r=e.type;return r||(r=y(t)?Uint16Array:Float32Array),new r(e.data)}function w(e,t){var r={};return Object.keys(t).forEach((function(n){if(!y(n)){var u=t[n],i=u.attrib||u.name||u.attribName||c.attribPrefix+n;if(u.value){if(!Array.isArray(u.value)&&!o.isArrayBuffer(u.value))throw new Error("array.value is not array or typedarray");r[i]={value:u.value}}else{var a,l,v,b;if(u.buffer&&u.buffer instanceof WebGLBuffer)a=u.buffer,b=u.numComponents||u.size,l=u.type,v=u.normalize;else if("number"==typeof u||"number"==typeof u.data){var m=u.data||u,w=u.type||Float32Array,h=m*w.BYTES_PER_ELEMENT;l=o.getGLTypeForTypedArrayType(w),v=void 0!==u.normalize?u.normalize:(E=w)===Int8Array||E===Uint8Array,b=u.numComponents||u.size||p(n,m),a=e.createBuffer(),e.bindBuffer(f,a),e.bufferData(f,h,u.drawType||35044)}else{var F=x(u,n);a=s(e,F,void 0,u.drawType),l=o.getGLTypeForTypedArray(F),v=void 0!==u.normalize?u.normalize:function(e){return e instanceof Int8Array||e instanceof Uint8Array}(F),b=d(u,n)}r[i]={buffer:a,numComponents:b,type:l,normalize:v,stride:u.stride||0,offset:u.offset||0,divisor:void 0===u.divisor?void 0:u.divisor,drawType:u.drawType}}}var E})),e.bindBuffer(f,null),r}var h=["position","positions","a_position"];function F(e,t,r){var n="indices"===r?34963:f;return s(e,x(t,r),n)}},function(e,t,r){"use strict";function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}t.__esModule=!0,t.setTextureDefaults_=function(e){i.copyExistingProperties(e,s),e.textureColor&&ye(e.textureColor)},t.createSampler=Fe,t.createSamplers=function(e,t){var r={};return Object.keys(t).forEach((function(n){r[n]=Fe(e,t[n])})),r},t.setSamplerParameters=he,t.createTexture=We,t.setEmptyTexture=ke,t.setTextureFromArray=Ce,t.loadTextureFromUrl=ge,t.setTextureFromElement=_e,t.setTextureFilteringForSize=Ee,t.setTextureParameters=we,t.setDefaultTextureColor=ye,t.createTextures=function(e,t,r){r=r||Se;var n=0,o=[],u={},i={};function a(){0===n&&setTimeout((function(){r(o.length?o:void 0,u,i)}),0)}return Object.keys(t).forEach((function(r){var f,c,l=t[r];("string"==typeof(c=l.src)||Array.isArray(c)&&"string"==typeof c[0])&&(f=function(e,t,u){i[r]=u,--n,e&&o.push(e),a()},++n),u[r]=We(e,l,f)})),a(),u},t.resizeTexture=function(e,t,r,n,o,u){n=n||r.width,o=o||r.height,u=u||r.depth;var i=r.target||E;e.bindTexture(i,t);var a,f=r.level||0,c=r.internalFormat||r.format||p,l=ie(c),s=r.format||l.format,v=r.src;a=v&&(y(v)||Array.isArray(v)&&"number"==typeof v[0])?r.type||le(e,v,l.type):r.type||l.type;if(i===A)for(var b=0;b<6;++b)e.texImage2D(_+b,f,c,n,o,0,s,a,null);else i===j||i===O?e.texImage3D(i,f,c,n,o,u,0,s,a,null):e.texImage2D(i,f,c,n,o,0,s,a,null)},t.canGenerateMipmap=fe,t.canFilter=ce,t.getNumComponentsForFormat=function(e){var t=re[e];if(!t)throw"unknown format: "+e;return t.u},t.getBytesPerElementForInternalFormat=ue,t.getFormatAndTypeForInternalFormat=ie;var o=f(r(3)),u=f(r(1)),i=f(r(0));function a(){if("function"!=typeof WeakMap)return null;var e=new WeakMap;return a=function(){return e},e}function f(e){if(e&&e.__esModule)return e;if(null===e||"object"!==n(e)&&"function"!=typeof e)return{default:e};var t=a();if(t&&t.has(e))return t.get(e);var r={},o=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var u in e)if(Object.prototype.hasOwnProperty.call(e,u)){var i=o?Object.getOwnPropertyDescriptor(e,u):null;i&&(i.get||i.set)?Object.defineProperty(r,u,i):r[u]=e[u]}return r.default=e,t&&t.set(e,r),r}var c,l,s={textureColor:new Uint8Array([128,192,255,255]),textureOptions:{},crossOrigin:void 0},y=u.isArrayBuffer,v=function(){return c=c||("undefined"!=typeof document&&document.createElement?document.createElement("canvas").getContext("2d"):null)},b=6406,m=6407,p=6408,d=6409,x=6410,w=6402,h=34041,F=33071,E=3553,A=34067,j=32879,O=35866,_=34069,S=10241,M=10240,R=10242,P=10243,T=3317,g=3314,z=32878,U=3316,C=3315,k=32877,W=37443,I=37441,B=37440,L=5120,G=5121,X=5122,D=5123,N=5124,H=5125,q=5126,V=5131,Y=36193,$=33640,K=33319,Z=33320,J=6403,Q=36244,ee=36248,te=36249,re={},ne=re;function oe(e){if(!l){var t={};t[6406]={v:b,h:!0,F:!0,A:[1,2,2,4],type:[G,V,Y,q]},t[6409]={v:d,h:!0,F:!0,A:[1,2,2,4],type:[G,V,Y,q]},t[6410]={v:x,h:!0,F:!0,A:[2,4,4,8],type:[G,V,Y,q]},t[m]={v:m,h:!0,F:!0,A:[3,6,6,12,2],type:[G,V,Y,q,33635]},t[p]={v:p,h:!0,F:!0,A:[4,8,8,16,2,2],type:[G,V,Y,q,32819,32820]},t[6402]={v:w,h:!0,F:!1,A:[2,4],type:[H,D]},t[33321]={v:J,h:!0,F:!0,A:[1],type:[G]},t[36756]={v:J,h:!1,F:!0,A:[1],type:[L]},t[33325]={v:J,h:!1,F:!0,A:[4,2],type:[q,V]},t[33326]={v:J,h:!1,F:!1,A:[4],type:[q]},t[33330]={v:Q,h:!0,F:!1,A:[1],type:[G]},t[33329]={v:Q,h:!0,F:!1,A:[1],type:[L]},t[33332]={v:Q,h:!0,F:!1,A:[2],type:[D]},t[33331]={v:Q,h:!0,F:!1,A:[2],type:[X]},t[33334]={v:Q,h:!0,F:!1,A:[4],type:[H]},t[33333]={v:Q,h:!0,F:!1,A:[4],type:[N]},t[33323]={v:K,h:!0,F:!0,A:[2],type:[G]},t[36757]={v:K,h:!1,F:!0,A:[2],type:[L]},t[33327]={v:K,h:!1,F:!0,A:[8,4],type:[q,V]},t[33328]={v:K,h:!1,F:!1,A:[8],type:[q]},t[33336]={v:Z,h:!0,F:!1,A:[2],type:[G]},t[33335]={v:Z,h:!0,F:!1,A:[2],type:[L]},t[33338]={v:Z,h:!0,F:!1,A:[4],type:[D]},t[33337]={v:Z,h:!0,F:!1,A:[4],type:[X]},t[33340]={v:Z,h:!0,F:!1,A:[8],type:[H]},t[33339]={v:Z,h:!0,F:!1,A:[8],type:[N]},t[32849]={v:m,h:!0,F:!0,A:[3],type:[G]},t[35905]={v:m,h:!1,F:!0,A:[3],type:[G]},t[36194]={v:m,h:!0,F:!0,A:[3,2],type:[G,33635]},t[36758]={v:m,h:!1,F:!0,A:[3],type:[L]},t[35898]={v:m,h:!1,F:!0,A:[12,6,4],type:[q,V,35899]},t[35901]={v:m,h:!1,F:!0,A:[12,6,4],type:[q,V,35902]},t[34843]={v:m,h:!1,F:!0,A:[12,6],type:[q,V]},t[34837]={v:m,h:!1,F:!1,A:[12],type:[q]},t[36221]={v:ee,h:!1,F:!1,A:[3],type:[G]},t[36239]={v:ee,h:!1,F:!1,A:[3],type:[L]},t[36215]={v:ee,h:!1,F:!1,A:[6],type:[D]},t[36233]={v:ee,h:!1,F:!1,A:[6],type:[X]},t[36209]={v:ee,h:!1,F:!1,A:[12],type:[H]},t[36227]={v:ee,h:!1,F:!1,A:[12],type:[N]},t[32856]={v:p,h:!0,F:!0,A:[4],type:[G]},t[35907]={v:p,h:!0,F:!0,A:[4],type:[G]},t[36759]={v:p,h:!1,F:!0,A:[4],type:[L]},t[32855]={v:p,h:!0,F:!0,A:[4,2,4],type:[G,32820,$]},t[32854]={v:p,h:!0,F:!0,A:[4,2],type:[G,32819]},t[32857]={v:p,h:!0,F:!0,A:[4],type:[$]},t[34842]={v:p,h:!1,F:!0,A:[16,8],type:[q,V]},t[34836]={v:p,h:!1,F:!1,A:[16],type:[q]},t[36220]={v:te,h:!0,F:!1,A:[4],type:[G]},t[36238]={v:te,h:!0,F:!1,A:[4],type:[L]},t[36975]={v:te,h:!0,F:!1,A:[4],type:[$]},t[36214]={v:te,h:!0,F:!1,A:[8],type:[D]},t[36232]={v:te,h:!0,F:!1,A:[8],type:[X]},t[36226]={v:te,h:!0,F:!1,A:[16],type:[N]},t[36208]={v:te,h:!0,F:!1,A:[16],type:[H]},t[33189]={v:w,h:!0,F:!1,A:[2,4],type:[D,H]},t[33190]={v:w,h:!0,F:!1,A:[4],type:[H]},t[36012]={v:w,h:!0,F:!1,A:[4],type:[q]},t[35056]={v:h,h:!0,F:!1,A:[4],type:[34042]},t[36013]={v:h,h:!0,F:!1,A:[4],type:[36269]},Object.keys(t).forEach((function(e){var r=t[e];r.bytesPerElementMap={},r.A.forEach((function(e,t){var n=r.type[t];r.bytesPerElementMap[n]=e}))})),l=t}return l[e]}function ue(e,t){var r=oe(e);if(!r)throw"unknown internal format";var n=r.bytesPerElementMap[t];if(void 0===n)throw"unknown internal format";return n}function ie(e){var t=oe(e);if(!t)throw"unknown internal format";return{format:t.v,type:t.type[0]}}function ae(e){return 0==(e&e-1)}function fe(e,t,r,n){if(!o.isWebGL2(e))return ae(t)&&ae(r);var u=oe(n);if(!u)throw"unknown internal format";return u.h&&u.F}function ce(e){var t=oe(e);if(!t)throw"unknown internal format";return t.F}function le(e,t,r){return y(t)?u.getGLTypeForTypedArray(t):r||G}function se(e,t,r,n,o){if(o%1!=0)throw"can't guess dimensions";if(r||n){if(n){if(!r&&(r=o/n)%1)throw"can't guess dimensions"}else if((n=o/r)%1)throw"can't guess dimensions"}else{var u=Math.sqrt(o/(t===A?6:1));u%1==0?(r=u,n=u):(r=o,n=1)}return{width:r,height:n}}function ye(e){s.textureColor=new Uint8Array([255*e[0],255*e[1],255*e[2],255*e[3]])}ne[6406]={u:1},ne[6409]={u:1},ne[6410]={u:2},ne[m]={u:3},ne[p]={u:4},ne[J]={u:1},ne[36244]={u:1},ne[K]={u:2},ne[33320]={u:2},ne[m]={u:3},ne[36248]={u:3},ne[p]={u:4},ne[36249]={u:4},ne[6402]={u:1},ne[34041]={u:2};var ve={};function be(e,t){void 0!==t.colorspaceConversion&&(ve.colorspaceConversion=e.getParameter(W),e.pixelStorei(W,t.colorspaceConversion)),void 0!==t.premultiplyAlpha&&(ve.premultiplyAlpha=e.getParameter(I),e.pixelStorei(I,t.premultiplyAlpha)),void 0!==t.flipY&&(ve.flipY=e.getParameter(B),e.pixelStorei(B,t.flipY))}function me(e,t){void 0!==t.colorspaceConversion&&e.pixelStorei(W,ve.colorspaceConversion),void 0!==t.premultiplyAlpha&&e.pixelStorei(I,ve.premultiplyAlpha),void 0!==t.flipY&&e.pixelStorei(B,ve.flipY)}function pe(e){ve.unpackAlignment=e.getParameter(T),o.isWebGL2(e)&&(ve.unpackRowLength=e.getParameter(g),ve.unpackImageHeight=e.getParameter(z),ve.unpackSkipPixels=e.getParameter(U),ve.unpackSkipRows=e.getParameter(C),ve.unpackSkipImages=e.getParameter(k))}function de(e){e.pixelStorei(T,ve.unpackAlignment),o.isWebGL2(e)&&(e.pixelStorei(g,ve.unpackRowLength),e.pixelStorei(z,ve.unpackImageHeight),e.pixelStorei(U,ve.unpackSkipPixels),e.pixelStorei(C,ve.unpackSkipRows),e.pixelStorei(k,ve.unpackSkipImages))}function xe(e,t,r,n){n.minMag&&(r.call(e,t,S,n.minMag),r.call(e,t,M,n.minMag)),n.min&&r.call(e,t,S,n.min),n.mag&&r.call(e,t,M,n.mag),n.wrap&&(r.call(e,t,R,n.wrap),r.call(e,t,P,n.wrap),(t===j||i.isSampler(e,t))&&r.call(e,t,32882,n.wrap)),n.wrapR&&r.call(e,t,32882,n.wrapR),n.wrapS&&r.call(e,t,R,n.wrapS),n.wrapT&&r.call(e,t,P,n.wrapT),n.minLod&&r.call(e,t,33082,n.minLod),n.maxLod&&r.call(e,t,33083,n.maxLod),n.baseLevel&&r.call(e,t,33084,n.baseLevel),n.maxLevel&&r.call(e,t,33085,n.maxLevel)}function we(e,t,r){var n=r.target||E;e.bindTexture(n,t),xe(e,n,e.texParameteri,r)}function he(e,t,r){xe(e,t,e.samplerParameteri,r)}function Fe(e,t){var r=e.createSampler();return he(e,r,t),r}function Ee(e,t,r,n,o,u){r=r||s.textureOptions,u=u||p;var i=r.target||E;if(n=n||r.width,o=o||r.height,e.bindTexture(i,t),fe(e,n,o,u))e.generateMipmap(i);else{var a=ce(u)?9729:9728;e.texParameteri(i,S,a),e.texParameteri(i,M,a),e.texParameteri(i,R,F),e.texParameteri(i,P,F)}}function Ae(e){return!0===e.auto||void 0===e.auto&&void 0===e.level}function je(e,t){return(t=t||{}).cubeFaceOrder||[_,34070,34071,34072,34073,34074]}function Oe(e,t){var r=je(0,t).map((function(e,t){return{face:e,ndx:t}}));return r.sort((function(e,t){return e.face-t.face})),r}function _e(e,t,r,n){var o=(n=n||s.textureOptions).target||E,u=n.level||0,i=r.width,a=r.height,f=n.internalFormat||n.format||p,c=ie(f),l=n.format||c.format,y=n.type||c.type;if(be(e,n),e.bindTexture(o,t),o===A){var b,m,d=r.width,x=r.height;if(d/6===x)b=x,m=[0,0,1,0,2,0,3,0,4,0,5,0];else if(x/6===d)b=d,m=[0,0,0,1,0,2,0,3,0,4,0,5];else if(d/3==x/2)b=d/3,m=[0,0,1,0,2,0,0,1,1,1,2,1];else{if(d/2!=x/3)throw"can't figure out cube map from element: "+(r.src?r.src:r.nodeName);b=d/2,m=[0,0,1,0,0,1,1,1,0,2,1,2]}var w=v();w?(w.canvas.width=b,w.canvas.height=b,i=b,a=b,Oe(0,n).forEach((function(t){var n=m[2*t.ndx+0]*b,o=m[2*t.ndx+1]*b;w.drawImage(r,n,o,b,b,0,0,b,b),e.texImage2D(t.face,u,f,l,y,w.canvas)})),w.canvas.width=1,w.canvas.height=1):"undefined"!=typeof createImageBitmap&&(i=b,a=b,Oe(0,n).forEach((function(c){var s=m[2*c.ndx+0]*b,v=m[2*c.ndx+1]*b;e.texImage2D(c.face,u,f,b,b,0,l,y,null),createImageBitmap(r,s,v,b,b,{premultiplyAlpha:"none",colorSpaceConversion:"none"}).then((function(r){be(e,n),e.bindTexture(o,t),e.texImage2D(c.face,u,f,l,y,r),me(e,n),Ae(n)&&Ee(e,t,n,i,a,f)}))})))}else if(o===j||o===O){var h=Math.min(r.width,r.height),F=Math.max(r.width,r.height),_=F/h;if(_%1!=0)throw"can not compute 3D dimensions of element";var S=r.width===F?1:0,M=r.height===F?1:0;pe(e),e.pixelStorei(T,1),e.pixelStorei(g,r.width),e.pixelStorei(z,0),e.pixelStorei(k,0),e.texImage3D(o,u,f,h,h,h,0,l,y,null);for(var R=0;R<_;++R){var P=R*h*S,W=R*h*M;e.pixelStorei(U,P),e.pixelStorei(C,W),e.texSubImage3D(o,u,0,0,R,h,h,1,l,y,r)}de(e)}else e.texImage2D(o,u,f,l,y,r);me(e,n),Ae(n)&&Ee(e,t,n,i,a,f),we(e,t,n)}function Se(){}function Me(e,t){return void 0!==t||function(e){if("undefined"!=typeof document){var t=document.createElement("a");return t.href=e,t.hostname===location.hostname&&t.port===location.port&&t.protocol===location.protocol}var r=new URL(location.href).origin;return new URL(e,location.href).origin===r}(e)?t:"anonymous"}function Re(e){return"undefined"!=typeof ImageBitmap&&e instanceof ImageBitmap||"undefined"!=typeof ImageData&&e instanceof ImageData||"undefined"!=typeof HTMLElement&&e instanceof HTMLElement}function Pe(e,t,r){return Re(e)?(setTimeout((function(){r(null,e)})),e):function(e,t,r){var n;if(r=r||Se,t=void 0!==t?t:s.crossOrigin,t=Me(e,t),"undefined"!=typeof Image){n=new Image,void 0!==t&&(n.crossOrigin=t);var o=function(){n.removeEventListener("error",u),n.removeEventListener("load",a),n=null},u=function(){var t="couldn't load image: "+e;i.error(t),r(t,n),o()},a=function(){r(null,n),o()};return n.addEventListener("error",u),n.addEventListener("load",a),n.src=e,n}if("undefined"!=typeof ImageBitmap){var f,c,l=function(){r(f,c)},y={};t&&(y.mode="cors"),fetch(e,y).then((function(e){if(!e.ok)throw e;return e.blob()})).then((function(e){return createImageBitmap(e,{premultiplyAlpha:"none",colorSpaceConversion:"none"})})).then((function(e){c=e,setTimeout(l)})).catch((function(e){f=e,setTimeout(l)})),n=null}return n}(e,t,r)}function Te(e,t,r){var n=(r=r||s.textureOptions).target||E;if(e.bindTexture(n,t),!1!==r.color){var o=function(e){return e=e||s.textureColor,y(e)?e:new Uint8Array([255*e[0],255*e[1],255*e[2],255*e[3]])}(r.color);if(n===A)for(var u=0;u<6;++u)e.texImage2D(_+u,0,p,1,1,0,p,G,o);else n===j||n===O?e.texImage3D(n,0,p,1,1,1,0,p,G,o):e.texImage2D(n,0,p,1,1,0,p,G,o)}}function ge(e,t,r,n){return n=n||Se,r=r||s.textureOptions,Te(e,t,r),Pe((r=Object.assign({},r)).src,r.crossOrigin,(function(o,u){o?n(o,t,u):(_e(e,t,u,r),n(null,t,u))}))}function ze(e,t,r,n){n=n||Se;var o=r.src;if(6!==o.length)throw"there must be 6 urls for a cubemap";var u=r.level||0,i=r.internalFormat||r.format||p,a=ie(i),f=r.format||a.format,c=r.type||G,l=r.target||E;if(l!==A)throw"target must be TEXTURE_CUBE_MAP";Te(e,t,r),r=Object.assign({},r);var s,y=6,v=[],b=je(0,r);s=o.map((function(o,a){return Pe(o,r.crossOrigin,(m=b[a],function(o,a){--y,o?v.push(o):a.width!==a.height?v.push("cubemap face img is not a square: "+a.src):(be(e,r),e.bindTexture(l,t),5===y?je().forEach((function(t){e.texImage2D(t,u,i,f,c,a)})):e.texImage2D(m,u,i,f,c,a),me(e,r),Ae(r)&&e.generateMipmap(l)),0===y&&n(v.length?v:void 0,t,s)}));var m}))}function Ue(e,t,r,n){n=n||Se;var o=r.src,u=r.internalFormat||r.format||p,i=ie(u),a=r.format||i.format,f=r.type||G,c=r.target||O;if(c!==j&&c!==O)throw"target must be TEXTURE_3D or TEXTURE_2D_ARRAY";Te(e,t,r),r=Object.assign({},r);var l,s=o.length,y=[],b=r.level||0,m=r.width,d=r.height,x=o.length,w=!0;l=o.map((function(o,i){return Pe(o,r.crossOrigin,(p=i,function(o,i){if(--s,o)y.push(o);else{if(be(e,r),e.bindTexture(c,t),w){w=!1,m=r.width||i.width,d=r.height||i.height,e.texImage3D(c,b,u,m,d,x,0,a,f,null);for(var h=0;h=0?h(n,t):r.indexOf("tan")>=0||r.indexOf("binorm")>=0?x(n,t):r.indexOf("norm")>=0&&w(n,t)})),e}function E(e,t,r){return e=e||2,{position:{numComponents:2,data:[(t=t||0)+-1*(e*=.5),(r=r||0)+-1*e,t+1*e,r+-1*e,t+-1*e,r+1*e,t+1*e,r+1*e]},normal:[0,0,1,0,0,1,0,0,1,0,0,1],texcoord:[0,0,1,0,0,1,1,1],indices:[0,1,2,2,1,3]}}function A(e,t,r,n,o){e=e||1,t=t||1,r=r||1,n=n||1,o=o||a.identity();for(var u=(r+1)*(n+1),i=b(3,u),f=b(3,u),c=b(2,u),l=0;l<=n;l++)for(var s=0;s<=r;s++){var y=s/r,v=l/n;i.push(e*y-.5*e,0,t*v-.5*t),f.push(0,1,0),c.push(y,v)}for(var m=r+1,p=b(3,r*n*2,Uint16Array),d=0;d 0");n=n||0,u=u||0;for(var a=(o=o||Math.PI)-n,f=(i=i||2*Math.PI)-u,c=(t+1)*(r+1),l=b(3,c),s=b(3,c),y=b(2,c),v=0;v<=r;v++)for(var m=0;m<=t;m++){var p=m/t,d=v/r,x=f*p+u,w=a*d+n,h=Math.sin(x),F=Math.cos(x),E=Math.sin(w),A=F*E,j=Math.cos(w),O=h*E;l.push(e*A,e*j,e*O),s.push(A,j,O),y.push(1-p,d)}for(var _=t+1,S=b(3,t*r*2,Uint16Array),M=0;Mo?(A=r,E=1,j=t):j=e+F/o*(t-e),-2!==F&&F!==o+2||(j=0,E=0),A-=r/2;for(var O=0;Oo?y.push(0,1,0):0===j?y.push(0,0,0):y.push(_*x,w,S*x),v.push(O/n,1-E)}}for(var M=0;M 0");var a=(i=i||1)-(u=u||0),c=2*(o+1)*4,l=b(3,c),s=b(3,c),y=b(2,c);function v(e,t,r){return e+(t-e)*r}function m(t,r,i,c,b,m){for(var p=0;p<=o;p++){var d=r/1,x=p/o,w=2*(d-.5),h=(u+x*a)*Math.PI,F=Math.sin(h),E=Math.cos(h),A=v(e,t,F),j=w*n,O=E*e,_=F*A;l.push(j,O,_);var S=f.add(f.multiply([0,F,E],i),c);s.push(S),y.push(d*b+m,x)}}for(var p=0;p<2;p++){var d=2*(p/1-.5);m(t,p,[1,1,1],[0,0,0],1,0),m(t,p,[0,0,0],[d,0,0],0,0),m(r,p,[1,1,1],[0,0,0],1,0),m(r,p,[0,0,0],[d,0,0],0,1)}var x=b(3,2*o*4,Uint16Array);function w(e,t){for(var r=0;r0&&p!==t){var h=l+(p+1),F=l+p,E=l+p-y,A=l+(p+1)-y;c.push(h,F,E),c.push(h,E,A)}}l+=t+1}return{position:i,normal:a,texcoord:f,indices:c}}function U(e){return function(t){var r=e.apply(this,Array.prototype.slice.call(arguments,1));return o.createBuffersFromArrays(t,r)}}function C(e){return function(t){var r=e.apply(null,Array.prototype.slice.call(arguments,1));return o.createBufferInfoFromArrays(t,r)}}var k=["numComponents","size","type","normalize","stride","offset","attrib","name","attribName"];function W(e,t,r,n){n=n||0;for(var o=e.length,u=0;u normalized device coords ([-1,1], y flipped so up is +1). const ndcX = (2.0 * screenX / viewportWidth) - 1.0; const ndcY = 1.0 - (2.0 * screenY / viewportHeight); // clip -> world: invert projection * view. const viewMatrix = m4.inverse(cameraWorldMatrix); const viewProj = m4.multiply(projectionMatrix, viewMatrix); const invViewProj = m4.inverse(viewProj); // unproject the NDC near (z=-1) and far (z=+1) points. m4.transformPoint // treats the input as a point (w=1) and applies the perspective divide, so // no manual matrix indexing / vec4 math is needed. const near = m4.transformPoint(invViewProj, [ndcX, ndcY, -1.0]); const far = m4.transformPoint(invViewProj, [ndcX, ndcY, 1.0]); // origin = camera world position; direction = near -> far. ray.setOrigin(cameraWorldMatrix[12], cameraWorldMatrix[13], cameraWorldMatrix[14]); ray.setDirection(far[0] - near[0], far[1] - near[1], far[2] - near[2]); return ray; }; }; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // dwRay.js // // Minimal, engine-neutral ray primitive: a normalized origin + direction pair. // It knows nothing about graphics, particles, collision, or physics — it is just // the geometric object those systems consume. Screen-to-world construction lives // in dwGfxMath; intersection queries live in dwCollision. // // Layout: origin[3], direction[3] (direction kept unit-length). // pointAt(distance, dst?) returns origin + direction * distance, writing into an // optional destination array to avoid per-call allocation on hot paths. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 'use strict'; class dwRay { constructor(origin, direction) { this.origin = [0.0, 0.0, 0.0]; this.direction = [0.0, 0.0, 1.0]; if (origin) { this.setOrigin(origin[0], origin[1], origin[2]); } if (direction) { this.setDirection(direction[0], direction[1], direction[2]); } } setOrigin(x, y, z) { this.origin[0] = x; this.origin[1] = y; this.origin[2] = z; return this; } // stores a normalized direction; a zero-length input is left as-is (defensive). setDirection(x, y, z) { const len = Math.hypot(x, y, z); if (len > 0.0) { const inv = 1.0 / len; this.direction[0] = x * inv; this.direction[1] = y * inv; this.direction[2] = z * inv; } return this; } set(ox, oy, oz, dx, dy, dz) { this.setOrigin(ox, oy, oz); this.setDirection(dx, dy, dz); return this; } // origin + direction * distance. Writes into dst (length >= 3) when supplied. pointAt(distance, dst) { const out = dst || [0.0, 0.0, 0.0]; out[0] = this.origin[0] + this.direction[0] * distance; out[1] = this.origin[1] + this.direction[1] * distance; out[2] = this.origin[2] + this.direction[2] * distance; return out; } } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // dwCollision.js // // Engine-neutral, query-only ray/primitive intersection helpers. This is NOT a // physics module: nothing here moves objects or resolves collisions. Every routine // consumes a dwRay and returns a consistent hit-result shape so callers can treat // results uniformly and pick the nearest with nearestHit(). // // Hit result: // { hit: bool, t: number, point: [x,y,z], normal: [x,y,z] } // - t is the ray parameter (distance along the unit direction) of the hit. // - point is ray.pointAt(t); normal is the surface normal at the hit (unit). // - a miss returns hit:false (t = Infinity); other fields are left untouched. // // Conventions: negative t (behind the origin) is a miss; an optional maxDistance // culls far hits; rays starting inside a sphere/AABB report the exit is ignored and // the first forward surface (or origin-plane) is returned per the notes on each fn. // No matrix math is required, so m4 is not used here. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 'use strict'; const dwCollision = { // reusable "miss" builder so every routine returns the same shape. _miss(dst) { const r = dst || { hit: false, t: Infinity, point: [0, 0, 0], normal: [0, 0, 0] }; r.hit = false; r.t = Infinity; return r; }, _fill(dst, ray, t, nx, ny, nz) { const r = dst || { hit: false, t: 0, point: [0, 0, 0], normal: [0, 0, 0] }; r.hit = true; r.t = t; r.point = ray.pointAt(t, r.point); r.normal[0] = nx; r.normal[1] = ny; r.normal[2] = nz; return r; }, // ---- ray vs infinite plane ------------------------------------------------- // plane defined by a point on it and a (need-not-be-unit) normal. Parallel rays // (denominator ~0) miss. The returned normal is unit-length and oriented as given. rayPlane(ray, planePoint, planeNormal, maxDistance, dst) { const nlen = Math.hypot(planeNormal[0], planeNormal[1], planeNormal[2]) || 1.0; const nx = planeNormal[0] / nlen; const ny = planeNormal[1] / nlen; const nz = planeNormal[2] / nlen; const denom = ray.direction[0] * nx + ray.direction[1] * ny + ray.direction[2] * nz; if (Math.abs(denom) < 1e-8) { return this._miss(dst); // parallel (or grazing) — no single hit. } const px = planePoint[0] - ray.origin[0]; const py = planePoint[1] - ray.origin[1]; const pz = planePoint[2] - ray.origin[2]; const t = (px * nx + py * ny + pz * nz) / denom; if (t < 0.0 || (maxDistance !== undefined && maxDistance !== null && t > maxDistance)) { return this._miss(dst); } return this._fill(dst, ray, t, nx, ny, nz); }, // ---- ray vs sphere --------------------------------------------------------- // Returns the nearest forward intersection. If the origin is inside the sphere, // the (forward) exit point is returned and its outward normal reported. raySphere(ray, center, radius, maxDistance, dst) { const ox = ray.origin[0] - center[0]; const oy = ray.origin[1] - center[1]; const oz = ray.origin[2] - center[2]; // direction is unit-length, so a = 1. const b = 2.0 * (ox * ray.direction[0] + oy * ray.direction[1] + oz * ray.direction[2]); const c = ox * ox + oy * oy + oz * oz - radius * radius; const disc = b * b - 4.0 * c; if (disc < 0.0) { return this._miss(dst); } const sq = Math.sqrt(disc); let t = (-b - sq) * 0.5; if (t < 0.0) { t = (-b + sq) * 0.5; // origin inside (or first hit behind): try far root. } if (t < 0.0 || (maxDistance !== undefined && maxDistance !== null && t > maxDistance)) { return this._miss(dst); } const hx = ray.origin[0] + ray.direction[0] * t; const hy = ray.origin[1] + ray.direction[1] * t; const hz = ray.origin[2] + ray.direction[2] * t; let nx = (hx - center[0]); let ny = (hy - center[1]); let nz = (hz - center[2]); const nl = Math.hypot(nx, ny, nz) || 1.0; return this._fill(dst, ray, t, nx / nl, ny / nl, nz / nl); }, // ---- ray vs axis-aligned box (slab method) --------------------------------- // boxMin/boxMax are opposite corners. Returns nearest forward entry; if the // origin is inside the box the exit face is returned. Axis-aligned face normal. rayAABB(ray, boxMin, boxMax, maxDistance, dst) { let tmin = -Infinity; let tmax = Infinity; let hitAxis = 0; let hitSign = -1.0; for (let a = 0; a < 3; a++) { const o = ray.origin[a]; const d = ray.direction[a]; const lo = boxMin[a]; const hi = boxMax[a]; if (Math.abs(d) < 1e-8) { // ray parallel to this slab: miss if origin outside the slab. if (o < lo || o > hi) { return this._miss(dst); } continue; } const inv = 1.0 / d; let t1 = (lo - o) * inv; let t2 = (hi - o) * inv; let sign = -1.0; if (t1 > t2) { const tmp = t1; t1 = t2; t2 = tmp; sign = 1.0; } if (t1 > tmin) { tmin = t1; hitAxis = a; hitSign = sign; } if (t2 < tmax) { tmax = t2; } if (tmin > tmax) { return this._miss(dst); } } // choose the first non-negative parameter (entry, or exit if inside). let t = tmin; let inside = false; if (t < 0.0) { t = tmax; inside = true; } if (t < 0.0 || (maxDistance !== undefined && maxDistance !== null && t > maxDistance)) { return this._miss(dst); } const nx = hitAxis === 0 ? (inside ? -hitSign : hitSign) : 0.0; const ny = hitAxis === 1 ? (inside ? -hitSign : hitSign) : 0.0; const nz = hitAxis === 2 ? (inside ? -hitSign : hitSign) : 0.0; // when inside, the entry axis is not meaningful; fall back to a generic +Y. if (inside && nx === 0.0 && ny === 0.0 && nz === 0.0) { return this._fill(dst, ray, t, 0.0, 1.0, 0.0); } return this._fill(dst, ray, t, nx, ny, nz); }, // ---- nearest of many hits -------------------------------------------------- // Given an array of hit results, return the one with the smallest t among hits. // Returns a miss shape if none hit. Does not allocate a new result. nearestHit(hits) { let best = null; for (let i = 0; i < hits.length; i++) { const h = hits[i]; if (h && h.hit && (best === null || h.t < best.t)) { best = h; } } return best || { hit: false, t: Infinity, point: [0, 0, 0], normal: [0, 0, 0] }; } }; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // dwGfxModelQuad - flat quad geometry FACTORY. // // This does NOT draw. It produces a twgl `arrays` object in the same shape dwGfxModel ingests // ({ position, normal, texcoord, indices, tangent:{numComponents:4, data} }) and hands it to // dwGfxModel.loadQuad(). The quad is the construction atom: one quad is a surface/plane, six make // a cube, two crossed make an "X" (the cheap volumetric card). Billboarding is NOT this factory's // concern - that is a render-time model-matrix behavior applied elsewhere. This only builds a flat // quad, as a surface first. // // Units: 1.0 = 1 cm (engine working unit). `size` is the full edge length in cm. // // Plane: which axis-plane the quad lies in, and therefore which way its face normal points: // "xy" -> lies in XY, normal +Z (default: a wall/surface facing the camera at rest) // "xz" -> lies in XZ, normal +Y (a floor lying flat) // "yz" -> lies in YZ, normal +X (a side wall) // // UVs are 0..1 across the face. A constant tangent basis is emitted per-plane so a normal map // lights correctly (tangent.w = handedness, matching the OBJ path's {numComponents:4} tangent). // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class dwGfxModelQuad { /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // build - returns a twgl `arrays` object for a flat quad. Static: the factory holds no state. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// static build(size = 1.0, plane = "xy") { let h = size * 0.5; // per-plane: four corner positions (CCW when viewed from the +normal side), the face // normal, and the tangent (direction of +U in world space) with handedness in .w. let position; let normal; let tangent; if (plane === "xz") { // lies flat on the ground, normal +Y. U -> +X, V -> +Z. position = [ -h, 0.0, -h, h, 0.0, -h, -h, 0.0, h, h, 0.0, h, ]; normal = [ 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, ]; tangent = [ 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, ]; } else if (plane === "yz") { // side wall, normal +X. U -> +Z, V -> +Y. position = [ 0.0, -h, -h, 0.0, -h, h, 0.0, h, -h, 0.0, h, h, ]; normal = [ 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, ]; tangent = [ 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, ]; } else { // default "xy": wall/surface facing +Z. U -> +X, V -> +Y. position = [ -h, -h, 0.0, h, -h, 0.0, -h, h, 0.0, h, h, 0.0, ]; normal = [ 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, ]; tangent = [ 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, ]; } // UVs 0..1 across the face, matching the corner order above. let texcoord = [ 0.0, 1.0, // bottom-left vertex now samples top-left texel 1.0, 1.0, // bottom-right → top-right 0.0, 0.0, // top-left → bottom-left 1.0, 0.0, // top-right → bottom-right ]; // two triangles (CCW), shared as an indexed quad. let indices = [ 0, 1, 2, 2, 1, 3, ]; return { position: position, normal: normal, texcoord: texcoord, indices: indices, tangent: { numComponents: 4, data: tangent }, }; } }; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 'use strict'; // ===================================================================================== // dwGfxDepthDiag -- self-contained WebGL2 depth-sampling diagnostic suite. // // One entry point: dwGfxDepthDiag(gl, opts) -> { table, verdict, decision, pass, stages }. // It runs a battery of RAW WebGL2 subtests that live entirely OUTSIDE the engine's texture // wrappers (its own programs / textures / FBOs / VAO), so a failure cannot be blamed on // dwGfxTexture, the fog composite, or any engine state. Every subtest: // - drains stale GL errors first, // - captures framebuffer completeness, gl.getError, depth attachment size/type, // TEXTURE_COMPARE_MODE, MIN/MAG filters, BASE/MAX level, DEPTH_WRITEMASK / CLEAR_VALUE // / FUNC / TEST, ACTIVE_TEXTURE and 2D bindings, // - decides PASS/FAIL numerically from an RGBA8 readback (never visual judgement). // All GL state is saved and restored; all temporary GL objects are deleted. Nothing about // the live scene is permanently altered. Sizes are tiny (4x4) so it is mobile-safe. // // The suite answers the standing question -- why does the fog read scene depth as 0 -- by // isolating each link of the chain and printing a decision the user reports verbatim. // ===================================================================================== function dwGfxDepthDiag(gl, opts) { opts = opts || {}; const engineDepthHandle = opts.engineDepthHandle || null; // dwGfx.getRenderSceneDepthTexture(0) const engineSceneFbo = opts.engineSceneFbo || null; // dwGfx.sceneFramebuffer const W = 4, H = 4; const TOL = 0.10; const near = (x, t) => (isFinite(x) && Math.abs(x - t) <= TOL); const num = (x) => (isFinite(x) ? x.toFixed(3) : "n/a"); if (!gl) { return { table: "depthdiag[no GL context]", verdict: "no GL", decision: "no GL", pass: {}, stages: [] }; } // ---- shared GLSL (ES 3.00) ---- const VS = `#version 300 es out vec2 v_uv; void main(){ vec2 p = vec2((gl_VertexID==1) ? 3.0 : -1.0, (gl_VertexID==2) ? 3.0 : -1.0); v_uv = p * 0.5 + 0.5; gl_Position = vec4(p, 0.0, 1.0); }`; const FS_RAW = `#version 300 es precision highp float; in vec2 v_uv; uniform highp sampler2D u_tex; // plain sampler2D: reads the stored value in .r out vec4 o; void main(){ o = vec4(vec3(texture(u_tex, v_uv).r), 1.0); }`; const FS_SHADOW = `#version 300 es precision highp float; in vec2 v_uv; uniform highp sampler2DShadow u_tex; // COMPARE_REF_TO_TEXTURE: returns 1.0/0.0 compare result uniform float u_ref; out vec4 o; void main(){ o = vec4(vec3(texture(u_tex, vec3(v_uv, u_ref))), 1.0); }`; // ---- save all GL state this suite touches ---- const S = { fbo: gl.getParameter(gl.FRAMEBUFFER_BINDING), vp: gl.getParameter(gl.VIEWPORT), prog: gl.getParameter(gl.CURRENT_PROGRAM), vao: gl.getParameter(gl.VERTEX_ARRAY_BINDING), active: gl.getParameter(gl.ACTIVE_TEXTURE), blend: gl.isEnabled(gl.BLEND), depthTest: gl.isEnabled(gl.DEPTH_TEST), cull: gl.isEnabled(gl.CULL_FACE), scissor: gl.isEnabled(gl.SCISSOR_TEST), depthMask: gl.getParameter(gl.DEPTH_WRITEMASK), colorMask: gl.getParameter(gl.COLOR_WRITEMASK), clearDepth: gl.getParameter(gl.DEPTH_CLEAR_VALUE), depthFunc: gl.getParameter(gl.DEPTH_FUNC), }; const savedUnitTex = []; for (let u = 0; u < 4; u++) { gl.activeTexture(gl.TEXTURE0 + u); savedUnitTex[u] = gl.getParameter(gl.TEXTURE_BINDING_2D); } // ---- resource bookkeeping (everything created here is deleted in finally) ---- const trash = { tex: [], fbo: [], prog: [], vao: [] }; const mkTexRGBA8 = (fillByte) => { const t = gl.createTexture(); trash.tex.push(t); gl.bindTexture(gl.TEXTURE_2D, t); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); let data = null; if (fillByte !== undefined) { data = new Uint8Array(W * H * 4); data.fill(fillByte); } gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, W, H, 0, gl.RGBA, gl.UNSIGNED_BYTE, data); return t; }; const mkTexDepth = (internal, format, type) => { const t = gl.createTexture(); trash.tex.push(t); gl.bindTexture(gl.TEXTURE_2D, t); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.NONE); gl.texImage2D(gl.TEXTURE_2D, 0, internal, W, H, 0, format, type, null); return t; }; const mkColorFbo = (colorTex) => { const f = gl.createFramebuffer(); trash.fbo.push(f); gl.bindFramebuffer(gl.FRAMEBUFFER, f); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorTex, 0); gl.drawBuffers([gl.COLOR_ATTACHMENT0]); return f; }; const mkDepthFbo = (depthTex) => { const f = gl.createFramebuffer(); trash.fbo.push(f); gl.bindFramebuffer(gl.FRAMEBUFFER, f); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTex, 0); gl.drawBuffers([gl.NONE]); gl.readBuffer(gl.NONE); return f; }; const mkProgram = (fsSrc) => { const vs = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(vs, VS); gl.compileShader(vs); const fs = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(fs, fsSrc); gl.compileShader(fs); const p = gl.createProgram(); gl.attachShader(p, vs); gl.attachShader(p, fs); gl.linkProgram(p); gl.deleteShader(vs); gl.deleteShader(fs); if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { const log = gl.getProgramInfoLog(p) || "link failed"; gl.deleteProgram(p); throw new Error("program link: " + log); } trash.prog.push(p); return p; }; const drain = () => { let n = 0; while (gl.getError() !== gl.NO_ERROR && n < 32) { n++; } }; const errStr = () => { const e = gl.getError(); return (e === gl.NO_ERROR) ? "NO_ERROR" : ("0x" + (e >>> 0).toString(16)); }; const vao = gl.createVertexArray(); trash.vao.push(vao); // Sample `srcTex` through `prog` on texture `unit` into a fresh RGBA8 output FBO, // readPixels the center pixel and return its red channel in [0,1]. `refVal` (shadow only). const outTex = mkTexRGBA8(0); const outFbo = mkColorFbo(outTex); const sampleCenter = (prog, srcTex, unit, refVal) => { gl.bindFramebuffer(gl.FRAMEBUFFER, outFbo); gl.viewport(0, 0, W, H); gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); gl.disable(gl.SCISSOR_TEST); gl.colorMask(true, true, true, true); gl.clearColor(0.0, 0.0, 0.0, 1.0); gl.clear(gl.COLOR_BUFFER_BIT); gl.useProgram(prog); gl.bindVertexArray(vao); gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, srcTex); const loc = gl.getUniformLocation(prog, "u_tex"); gl.uniform1i(loc, unit); if (refVal !== undefined) { const rl = gl.getUniformLocation(prog, "u_ref"); gl.uniform1f(rl, refVal); } gl.drawArrays(gl.TRIANGLES, 0, 3); const px = new Uint8Array(4); gl.readPixels(W >> 1, H >> 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); return { v: px[0] / 255, unitOK: (gl.getParameter(gl.ACTIVE_TEXTURE) === (gl.TEXTURE0 + unit)), bindOK: (gl.getParameter(gl.TEXTURE_BINDING_2D) === srcTex), locOK: (loc !== null) }; }; // capture the depth-attachment + sampler state of the currently bound FBO / a tex. const captureDepthState = (fboBound, tex) => { let complete = "n/a", dsize = "?", dtype = "?"; if (fboBound) { complete = (gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) ? "COMPLETE" : "INCOMPLETE"; try { dsize = String(gl.getFramebufferAttachmentParameter(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE)); const ct = gl.getFramebufferAttachmentParameter(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE); dtype = (ct === gl.FLOAT) ? "FLOAT" : (ct === gl.UNSIGNED_NORMALIZED) ? "UNORM" : (ct === gl.UNSIGNED_INT) ? "UINT" : String(ct); } catch (e) { dsize = "?"; } } let cmp = "?", minF = "?", magF = "?", base = "?", max = "?"; if (tex) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tex); const cm = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE); cmp = (cm === gl.NONE) ? "NONE" : (cm === gl.COMPARE_REF_TO_TEXTURE) ? "COMPARE_REF" : String(cm); const fn = (f) => (f === gl.NEAREST) ? "NEAREST" : (f === gl.LINEAR) ? "LINEAR" : String(f); minF = fn(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER)); magF = fn(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER)); base = String(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_BASE_LEVEL)); max = String(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAX_LEVEL)); } return "fbo=" + complete + " dsize=" + dsize + " dtype=" + dtype + " cmp=" + cmp + " min=" + minF + " mag=" + magF + " base=" + base + " max=" + max + " wmask=" + (gl.getParameter(gl.DEPTH_WRITEMASK) ? 1 : 0) + " cval=" + num(gl.getParameter(gl.DEPTH_CLEAR_VALUE)) + " dtest=" + (gl.isEnabled(gl.DEPTH_TEST) ? "ON" : "OFF") + " act=" + (gl.getParameter(gl.ACTIVE_TEXTURE) - gl.TEXTURE0); }; const stages = []; const rec = (id, passVal, detail) => { stages.push({ id, pass: !!passVal, detail }); }; // build a standalone depth texture cleared to `sentinel` (no geometry drawn). const makeClearedDepth = (internal, format, type, sentinel) => { const t = mkTexDepth(internal, format, type); const f = mkDepthFbo(t); const state = captureDepthState(true, t); gl.bindFramebuffer(gl.FRAMEBUFFER, f); gl.disable(gl.SCISSOR_TEST); gl.depthMask(true); gl.clearDepth(sentinel); gl.clear(gl.DEPTH_BUFFER_BIT); return { tex: t, fbo: f, state, err: errStr() }; }; const P = {}; // pass flags used by the classifier try { // ---------- 1) COLOR CONTROL (RGBA8 0.5 through the minimal fullscreen path) ---------- drain(); { const progRaw = mkProgram(FS_RAW); const src = mkTexRGBA8(128); // 128/255 = 0.502 const r = sampleCenter(progRaw, src, 0); P.color = near(r.v, 0.5) && r.unitOK && r.bindOK && r.locOK; rec("1 COLOR CONTROL", P.color, "read=" + num(r.v) + " (want ~0.5) unitOK=" + r.unitOK + " bindOK=" + r.bindOK + " locOK=" + r.locOK + " err=" + errStr()); } // ---------- 2) STANDALONE DEPTH24 (raw sampler2D) ---------- drain(); { const progRaw = mkProgram(FS_RAW); const d = makeClearedDepth(gl.DEPTH_COMPONENT24, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, 0.5); const r = sampleCenter(progRaw, d.tex, 0); P.depth24 = near(r.v, 0.5); rec("2 STANDALONE DEPTH24", P.depth24, "read=" + num(r.v) + " (want ~0.5) clearErr=" + d.err + " " + d.state + " sampleErr=" + errStr()); } // ---------- 3) STANDALONE DEPTH32F (raw sampler2D) ---------- drain(); { const progRaw = mkProgram(FS_RAW); const d = makeClearedDepth(gl.DEPTH_COMPONENT32F, gl.DEPTH_COMPONENT, gl.FLOAT, 0.5); const r = sampleCenter(progRaw, d.tex, 0); P.depth32f = near(r.v, 0.5); rec("3 STANDALONE DEPTH32F", P.depth32f, "read=" + num(r.v) + " (want ~0.5) clearErr=" + d.err + " " + d.state + " sampleErr=" + errStr()); } // ---------- 4) SAMPLER MODES (raw sampler2D vs sampler2DShadow around 0.5) ---------- drain(); { const progRaw = mkProgram(FS_RAW); const progShadow = mkProgram(FS_SHADOW); const d = makeClearedDepth(gl.DEPTH_COMPONENT24, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, 0.5); // raw read (COMPARE_MODE stays NONE) const rawR = sampleCenter(progRaw, d.tex, 0); // switch to compare mode for the shadow sampler gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, d.tex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL); const lo = sampleCenter(progShadow, d.tex, 0, 0.25); // 0.25<=0.5 => 1.0 const hi = sampleCenter(progShadow, d.tex, 0, 0.75); // 0.75<=0.5 => 0.0 P.rawSampler = near(rawR.v, 0.5); P.shadow = near(lo.v, 1.0) && near(hi.v, 0.0); rec("4 SAMPLER MODES", P.rawSampler && P.shadow, "raw=" + num(rawR.v) + " (want ~0.5) shadow(ref0.25)=" + num(lo.v) + " (want ~1) shadow(ref0.75)=" + num(hi.v) + " (want ~0) => stored~0.5:" + (P.shadow ? "YES" : "NO") + " err=" + errStr()); } // ---------- 5) TEXTURE UNITS (explicit non-zero unit, verify bindings, no feedback) ---------- drain(); { const progRaw = mkProgram(FS_RAW); const src = mkTexRGBA8(128); const r = sampleCenter(progRaw, src, 3); // deliberately unit 3 const noFeedback = (src !== outTex); // source must not be the output attachment P.units = near(r.v, 0.5) && r.unitOK && r.bindOK && r.locOK && noFeedback; rec("5 TEXTURE UNITS", P.units, "unit=3 read=" + num(r.v) + " activeOK=" + r.unitOK + " bindOK=" + r.bindOK + " samplerLocOK=" + r.locOK + " noFeedback=" + noFeedback + " err=" + errStr()); } // ---------- 6) MULTIPLE DEPTH (two standalone depth textures, distinct sentinels) ---------- drain(); { const progRaw = mkProgram(FS_RAW); const dA = makeClearedDepth(gl.DEPTH_COMPONENT24, gl.DEPTH_COMPONENT, gl.UNSIGNED_INT, 0.25); const dB = makeClearedDepth(gl.DEPTH_COMPONENT32F, gl.DEPTH_COMPONENT, gl.FLOAT, 0.75); // sample each independently while the engine's shadow/scene depth resources stay alive const ra = sampleCenter(progRaw, dA.tex, 0); const rb = sampleCenter(progRaw, dB.tex, 1); const aOK = near(ra.v, 0.25), bOK = near(rb.v, 0.75); P.twoBoth = aOK && bOK; P.twoOneOnly = (aOK !== bOK); // exactly one worked => resource/driver interaction rec("6 MULTIPLE DEPTH", P.twoBoth, "A(24)=" + num(ra.v) + " (want ~0.25) B(32F)=" + num(rb.v) + " (want ~0.75) bothOK=" + P.twoBoth + " oneOnly=" + P.twoOneOnly + " engineResourcesAlive=" + (!!engineDepthHandle) + " err=" + errStr()); } // ---------- 7) ENGINE ATTACHMENT (identical raw path vs the live scene depth) ---------- drain(); { let detail, enginePass = false; if (!engineDepthHandle || !gl.isTexture(engineDepthHandle)) { detail = "engineDepthHandle=" + (engineDepthHandle ? "not-a-texture" : "null (renderbuffer/fog-off path)"); } else { const progRaw = mkProgram(FS_RAW); const r = sampleCenter(progRaw, engineDepthHandle, 0); let fboState = "sceneFbo=n/a"; if (engineSceneFbo) { gl.bindFramebuffer(gl.FRAMEBUFFER, engineSceneFbo); const complete = (gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE); const type = gl.getFramebufferAttachmentParameter(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE); const name = (type === gl.TEXTURE) ? gl.getFramebufferAttachmentParameter(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.FRAMEBUFFER_ATTACHMENT_OBJECT_NAME) : null; const tstr = (type === gl.TEXTURE) ? ("TEX id=" + (name === engineDepthHandle ? "OK" : "MISMATCH")) : (type === gl.RENDERBUFFER) ? "RBUF" : (type === gl.NONE) ? "NONE" : String(type); fboState = "sceneFbo=" + (complete ? "COMPLETE" : "INCOMPLETE") + " att=" + tstr; } // engine attachment "reads" if it returns a plausible depth (not black 0). enginePass = !near(r.v, 0.0); detail = "read=" + num(r.v) + " (production; 0=black) " + fboState + " err=" + errStr(); } P.engine = enginePass; rec("7 ENGINE ATTACHMENT", P.engine, detail); } } catch (e) { rec("SUITE ERROR", false, (e && e.message ? e.message : String(e))); } finally { // ---- delete every temp GL object ---- for (const f of trash.fbo) { try { gl.deleteFramebuffer(f); } catch (e) {} } for (const t of trash.tex) { try { gl.deleteTexture(t); } catch (e) {} } for (const p of trash.prog) { try { gl.deleteProgram(p); } catch (e) {} } for (const v of trash.vao) { try { gl.deleteVertexArray(v); } catch (e) {} } // ---- restore all GL state exactly ---- for (let u = 0; u < 4; u++) { gl.activeTexture(gl.TEXTURE0 + u); gl.bindTexture(gl.TEXTURE_2D, savedUnitTex[u]); } gl.activeTexture(S.active); gl.bindVertexArray(S.vao); gl.useProgram(S.prog); if (S.blend) { gl.enable(gl.BLEND); } else { gl.disable(gl.BLEND); } if (S.depthTest) { gl.enable(gl.DEPTH_TEST); } else { gl.disable(gl.DEPTH_TEST); } if (S.cull) { gl.enable(gl.CULL_FACE); } else { gl.disable(gl.CULL_FACE); } if (S.scissor) { gl.enable(gl.SCISSOR_TEST); } else { gl.disable(gl.SCISSOR_TEST); } gl.depthMask(S.depthMask); gl.colorMask(S.colorMask[0], S.colorMask[1], S.colorMask[2], S.colorMask[3]); gl.clearDepth(S.clearDepth); gl.depthFunc(S.depthFunc); gl.bindFramebuffer(gl.FRAMEBUFFER, S.fbo); gl.viewport(S.vp[0], S.vp[1], S.vp[2], S.vp[3]); } // ---- decision classifier (priority order mirrors the standing hypothesis tree) ---- const standaloneRaw = !!(P.depth24 || P.depth32f); // raw sampler2D can read a standalone depth tex let decision; if (!P.color) { decision = "FULLSCREEN SHADER / TEXTURE-UNIT / BINDING PATH is broken (color control failed) -- fix the sampling plumbing before trusting any depth result."; } else if (!standaloneRaw && P.shadow) { decision = "COMPARE-MODE / SAMPLER MISMATCH -- depth IS stored (sampler2DShadow compare works) but a plain sampler2D returns 0 on this device. FIX: sample scene depth with a shadow sampler OR use a color-encoded depth pass."; } else if (!standaloneRaw && !P.shadow) { decision = "BROWSER / FORMAT / SAMPLER PATH -- this device cannot sample a depth texture as a value at all (raw AND shadow failed on a standalone depth texture). FIX: render linear depth into an RGBA8/R32F COLOR attachment and sample that."; } else if (P.twoOneOnly) { decision = "RESOURCE / DRIVER INTERACTION -- two simultaneous standalone depth textures do not sample independently (one passed, one failed). Investigate driver depth-texture aliasing / unit state."; } else if (!P.engine) { decision = "ENGINE ATTACHMENT / STATE / LIFECYCLE -- standalone depth sampling works, but the engine's live scene DEPTH_ATTACHMENT reads black. The isolated path is fine, so the fault is attachment identity / FBO completeness / texture recreation lifecycle in the engine scene target."; } else { decision = "SCENE PASS OVERWRITE / ORDER / DEPTH-WRITE STATE -- every isolated path passes AND the engine attachment reads a real depth here. So a black production Mode 4 is a scene-pass issue: depth writes disabled, cleared after the draw, wrong order, or all logo texels alpha-discarded."; } // ---- build the compact PASS/FAIL table (HUD + console) ---- const mark = (b) => (b ? "PASS" : "FAIL"); let table = "==== dwGfxDepthDiag (RGBA8 readback, tol " + TOL.toFixed(2) + ") ====\n"; for (const st of stages) { table += mark(st.pass) + " " + st.id + " | " + st.detail + "\n"; } table += "DECISION: " + decision; const verdict = "depthdiag[color:" + mark(P.color) + " d24:" + mark(!!P.depth24) + " d32f:" + mark(!!P.depth32f) + " shadow:" + mark(!!P.shadow) + " units:" + mark(!!P.units) + " two:" + mark(!!P.twoBoth) + " engine:" + mark(!!P.engine) + "]"; console.log(table); return { table, verdict, decision, pass: P, stages }; } // ===================================================================================== // dwGfxMakeDepthProbe(gl) -> reusable, proven-good raw depth sampler. // // The one-key `j` suite established (runtime, desktop Chrome) that a plain sampler2D CAN // read the live engine depth attachment: color/d24/d32f/shadow/units/two/engine all PASS. // Yet the fog composite reads the SAME texture as 0 at BOTH the far-plane corner and the // center during the normal frame (HUD "depth[TEX id=OK | bg 0.000 ctr 0.000]"). A uniform // zero (corner included) is a SAMPLING-STATE failure, not missing geometry depth. // // This factory builds ONE cached clean sampler (own program / VAO / 4x4 RGBA8 output FBO) // -- byte-identical to the suite's proven-good path -- so a caller can, at any frame // checkpoint, sample the engine depth through a KNOWN-GOOD path and simultaneously read // back the depth texture's live sampler state (TEXTURE_COMPARE_MODE, MIN/MAG filter, // BASE/MAX level) and the currently bound framebuffer. Comparing the clean read against // the fog's own read AT THE SAME INSTANT localizes the first zero transition and whether // the fault is the texture's sampler state (e.g. COMPARE_MODE left in COMPARE_REF_TO_TEXTURE // so a plain sampler2D returns 0) rather than the depth content. // // probe.sample(depthTex, { forceCompareNone }) -> { v, vNone, cmp, minF, magF, base, max, // boundFbo, feedback, err }. All GL state touched is saved and restored; the probe never // alters the sampled texture permanently (a forced COMPARE_MODE is restored). dispose() // deletes the cached GL objects. Mobile-safe (4x4). // ===================================================================================== function dwGfxMakeDepthProbe(gl) { if (!gl) { return null; } const W = 4, H = 4; const VS = `#version 300 es out vec2 v_uv; void main(){ vec2 p = vec2((gl_VertexID==1) ? 3.0 : -1.0, (gl_VertexID==2) ? 3.0 : -1.0); v_uv = p * 0.5 + 0.5; gl_Position = vec4(p, 0.0, 1.0); }`; const FS_RAW = `#version 300 es precision highp float; in vec2 v_uv; uniform highp sampler2D u_tex; out vec4 o; void main(){ o = vec4(vec3(texture(u_tex, v_uv).r), 1.0); }`; const compile = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return s; }; const vs = compile(gl.VERTEX_SHADER, VS); const fs = compile(gl.FRAGMENT_SHADER, FS_RAW); const prog = gl.createProgram(); gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); gl.deleteShader(vs); gl.deleteShader(fs); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { gl.deleteProgram(prog); return null; } const uTexLoc = gl.getUniformLocation(prog, "u_tex"); const vao = gl.createVertexArray(); const outTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, outTex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, W, H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); const outFbo = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, outFbo); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, outTex, 0); gl.drawBuffers([gl.COLOR_ATTACHMENT0]); gl.bindFramebuffer(gl.FRAMEBUFFER, null); const errStr = () => { const e = gl.getError(); return (e === gl.NO_ERROR) ? "NO_ERROR" : ("0x" + (e >>> 0).toString(16)); }; const drain = () => { let n = 0; while (gl.getError() !== gl.NO_ERROR && n < 32) { n++; } }; const fname = (f) => (f === gl.NEAREST) ? "NEAREST" : (f === gl.LINEAR) ? "LINEAR" : (f === gl.NEAREST_MIPMAP_NEAREST) ? "N_MIP_N" : (f === gl.LINEAR_MIPMAP_LINEAR) ? "L_MIP_L" : String(f); // One clean raw-sampler read of `depthTex` into the private 4x4 FBO, returning the // center red channel in [0,1]. Never a feedback loop: the private FBO is bound first. const readOnce = (depthTex) => { gl.bindFramebuffer(gl.FRAMEBUFFER, outFbo); gl.viewport(0, 0, W, H); gl.disable(gl.DEPTH_TEST); gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); gl.disable(gl.SCISSOR_TEST); gl.colorMask(true, true, true, true); gl.clearColor(0, 0, 0, 1); gl.clear(gl.COLOR_BUFFER_BIT); gl.useProgram(prog); gl.bindVertexArray(vao); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, depthTex); gl.uniform1i(uTexLoc, 0); gl.drawArrays(gl.TRIANGLES, 0, 3); const px = new Uint8Array(4); gl.readPixels(W >> 1, H >> 1, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); return px[0] / 255; }; return { sample(depthTex, opts) { opts = opts || {}; if (!depthTex || !gl.isTexture(depthTex)) { return { v: NaN, vNone: NaN, cmp: "no-tex", minF: "?", magF: "?", base: "?", max: "?", boundFbo: null, feedback: false, err: "no-tex" }; } drain(); // ---- save state ---- const sFbo = gl.getParameter(gl.FRAMEBUFFER_BINDING); const sVp = gl.getParameter(gl.VIEWPORT); const sProg = gl.getParameter(gl.CURRENT_PROGRAM); const sVao = gl.getParameter(gl.VERTEX_ARRAY_BINDING); const sActive = gl.getParameter(gl.ACTIVE_TEXTURE); const sBlend = gl.isEnabled(gl.BLEND); const sDepth = gl.isEnabled(gl.DEPTH_TEST); const sCull = gl.isEnabled(gl.CULL_FACE); const sScis = gl.isEnabled(gl.SCISSOR_TEST); const sCmask = gl.getParameter(gl.COLOR_WRITEMASK); gl.activeTexture(gl.TEXTURE0); const sTex0 = gl.getParameter(gl.TEXTURE_BINDING_2D); // Is `depthTex` attached to the framebuffer that is bound RIGHT NOW? If so, a sample // there (as the fog would do if it sampled while that fbo was bound) is a feedback // loop that yields 0 -- a distinct, reportable cause. (The clean read below rebinds // the private fbo first, so IT is never a feedback loop.) let feedback = false; try { if (sFbo) { const at = gl.getFramebufferAttachmentParameter(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE); if (at === gl.TEXTURE) { const nm = gl.getFramebufferAttachmentParameter(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.FRAMEBUFFER_ATTACHMENT_OBJECT_NAME); feedback = (nm === depthTex); } } } catch (e) { feedback = false; } // read the live sampler state of depthTex. gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, depthTex); const cmRaw = gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE); const cmp = (cmRaw === gl.NONE) ? "NONE" : (cmRaw === gl.COMPARE_REF_TO_TEXTURE) ? "COMPARE_REF" : String(cmRaw); const minF = fname(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER)); const magF = fname(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER)); const base = String(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_BASE_LEVEL)); const max = String(gl.getTexParameter(gl.TEXTURE_2D, gl.TEXTURE_MAX_LEVEL)); // (1) read exactly as the texture is configured now (mirrors what the fog samples). const v = readOnce(depthTex); // (2) optional: read with COMPARE_MODE forced NONE (restored after). If v~0 but // vNone is a real depth, the fault is a stale COMPARE_REF_TO_TEXTURE on the scene // depth texture -- a one-line, non-speculative state fix in the fog composite. let vNone = NaN; if (opts.forceCompareNone && cmRaw !== gl.NONE) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, depthTex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, gl.NONE); vNone = readOnce(depthTex); gl.bindTexture(gl.TEXTURE_2D, depthTex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_COMPARE_MODE, cmRaw); // restore exactly } const err = errStr(); // ---- restore state ---- gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, sTex0); gl.activeTexture(sActive); gl.bindVertexArray(sVao); gl.useProgram(sProg); if (sBlend) { gl.enable(gl.BLEND); } else { gl.disable(gl.BLEND); } if (sDepth) { gl.enable(gl.DEPTH_TEST); } else { gl.disable(gl.DEPTH_TEST); } if (sCull) { gl.enable(gl.CULL_FACE); } else { gl.disable(gl.CULL_FACE); } if (sScis) { gl.enable(gl.SCISSOR_TEST); } else { gl.disable(gl.SCISSOR_TEST); } gl.colorMask(sCmask[0], sCmask[1], sCmask[2], sCmask[3]); gl.bindFramebuffer(gl.FRAMEBUFFER, sFbo); gl.viewport(sVp[0], sVp[1], sVp[2], sVp[3]); return { v, vNone, cmp, minF, magF, base, max, boundFbo: sFbo, feedback, err }; }, dispose() { try { gl.deleteFramebuffer(outFbo); } catch (e) {} try { gl.deleteTexture(outTex); } catch (e) {} try { gl.deleteProgram(prog); } catch (e) {} try { gl.deleteVertexArray(vao); } catch (e) {} } }; } // Expose globally (matches the demo's other window-level helpers). if (typeof window !== "undefined") { window.dwGfxDepthDiag = dwGfxDepthDiag; window.dwGfxMakeDepthProbe = dwGfxMakeDepthProbe; } if (typeof module !== "undefined" && module.exports) { module.exports = dwGfxDepthDiag; module.exports.dwGfxMakeDepthProbe = dwGfxMakeDepthProbe; } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // dwGfxNode - parent/child transform graph node. // // Every node owns a local Translate/Rotate/Scale, composes it into a local matrix, and multiplies by its // parent's world matrix to produce its own world matrix. Transforming a parent therefore carries through to // every child (models, decals, lights, camera views). A node is payload-agnostic: attach a dwGfxModel, a light // config, or a dwGfxView via .attach() and it inherits the parent transform for free. // // Matrix convention matches the rest of the engine (m4, column-major): worldMatrix = parentWorld * localMatrix, // i.e. the local transform is applied first, then the parent's. Rotation order is Z * Y * X (yaw/pitch/roll // applied as X first, then Y, then Z), matching the dwDrawInfo x/y/zRotation fields. // /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class dwGfxNode { constructor(name = "") { this.name = name; // local transform (TRS) this.position = [0.0, 0.0, 0.0]; this.rotation = [0.0, 0.0, 0.0]; // radians, applied X then Y then Z this.scale = [1.0, 1.0, 1.0]; // cached matrices this.localMatrix = m4.identity(); this.worldMatrix = m4.identity(); // graph links this.parent = null; this.children = []; // optional payload that rides this node's world transform this.payload = null; this.payloadType = ""; // "model" | "light" | "view" | "" (empty group) this.visible = true; } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // graph management /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// add(child) { // re-parent cleanly if the child already lives somewhere if (child.parent) { child.parent.remove(child); } child.parent = this; this.children.push(child); return child; } remove(child) { let index = this.children.indexOf(child); if (index >= 0) { this.children.splice(index, 1); child.parent = null; } return child; } attach(payload, payloadType) { this.payload = payload; this.payloadType = payloadType; return this; } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // local transform setters (chainable) /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// setPosition(x, y, z) { this.position = [x, y, z]; return this; } setRotation(x, y, z) { this.rotation = [x, y, z]; return this; } setScale(x, y, z) { this.scale = [x, y, z]; return this; } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // matrix build - compose local TRS, then fold in the parent's world matrix /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// computeLocalMatrix() { let m = m4.translation(this.position[0], this.position[1], this.position[2]); m = m4.multiply(m, m4.xRotation(this.rotation[0])); m = m4.multiply(m, m4.yRotation(this.rotation[1])); m = m4.multiply(m, m4.zRotation(this.rotation[2])); m = m4.multiply(m, m4.scaling(this.scale[0], this.scale[1], this.scale[2])); this.localMatrix = m; return this.localMatrix; } updateWorldMatrix(parentWorldMatrix = null) { this.computeLocalMatrix(); if (parentWorldMatrix) { // worldMatrix = parentWorld * localMatrix this.worldMatrix = m4.multiply(parentWorldMatrix, this.localMatrix); } else { // root node - its local transform is already world space this.worldMatrix = m4.copy(this.localMatrix); } // push the freshly computed world matrix down to every child for (let i = 0; i < this.children.length; i++) { this.children[i].updateWorldMatrix(this.worldMatrix); } } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // traversal - depth first, invisible branches skipped /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// traverse(callback) { if (!this.visible) { return; } callback(this); for (let i = 0; i < this.children.length; i++) { this.children[i].traverse(callback); } } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // helpers - pull world-space data out of the cached world matrix /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// getWorldPosition() { // translation lives in the 13th..15th slots of a column-major 4x4 return this.worldMatrix.slice(12, 15); } getWorldMatrix() { return this.worldMatrix; } }; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////// // This is needed if the images are not on the same domain // NOTE: The server providing the images must give CORS permissions // in order to be able to use the image with WebdwGfx.gl(). Most sites // do NOT give permission. /////////////////////////////////////////////////////////////////////////////////////////////////// class dwImage extends Image { constructor() { super(); this.ID = -1; this.drawInfo = new dwDrawInfo(); } }; /////////////////////////////////////////////////////////////////////////////////////////////////// class dwGfxTexture { static textureCount = 0; static TextureType = Object.freeze({ COLOR_2D: 0, NORMAL_MAP: 1, CUBE_MAP_FACE: 2, COLOR_BUFFER_2D: 3, COLOR_BUFFER_HDR_2D: 4, }); constructor() { this.img = new dwImage(); this.textureTarget = 0; this.isReady = false; this.loadedFaces = 0; } requestCORSIfNotSameOrigin(img, url) { if ((new URL(url, window.location.href)).origin !== window.location.origin) { img.crossOrigin = ""; } } loadImage( url, useMips = 1, textureType = dwGfxTexture.TextureType.COLOR_2D, clamp = 0, existingTexture = null, cubeFaceTarget = null ) { const textureOwner = this; const img = new dwImage(); img.drawInfo.src = url; img.ID = dwGfxTexture.textureCount++; textureOwner.requestCORSIfNotSameOrigin(img, url); img.loading = "eager"; img.addEventListener('load', function(event) { const dwGL = dwGfx.gl(); const isCubeFace = (textureType === dwGfxTexture.TextureType.CUBE_MAP_FACE); const bindTarget = isCubeFace ? dwGL.TEXTURE_CUBE_MAP : dwGL.TEXTURE_2D; const uploadTarget = isCubeFace ? cubeFaceTarget : dwGL.TEXTURE_2D; let tex = existingTexture; if (isCubeFace) { console.log("CUBEFACE target:", uploadTarget, "url:", url, "size:", img.width, img.height); console.log("CUBEFACE gl error after texImage2D:", dwGL.getError()); } if (isCubeFace && !tex) { throw new Error("Cubemap face upload requires existing cubemap texture."); } if (!tex) { tex = dwGL.createTexture(); } if (isCubeFace) { // use existingTexture only; do not replace owner resource handle } else { textureOwner.textureTarget = bindTarget; textureOwner.img.drawInfo.texture = tex; } textureOwner.img.drawInfo.width = img.width; textureOwner.img.drawInfo.height = img.height; dwGL.bindTexture(bindTarget, tex); if (textureType === dwGfxTexture.TextureType.NORMAL_MAP) { dwGL.pixelStorei(dwGL.UNPACK_COLORSPACE_CONVERSION_WEBGL, dwGL.NONE); } const ext = dwGL.getExtension('EXT_texture_filter_anisotropic') || dwGL.getExtension('MOZ_EXT_texture_filter_anisotropic') || dwGL.getExtension('WEBKIT_EXT_texture_filter_anisotropic'); dwGL.texImage2D( uploadTarget, 0, dwGL.RGBA, dwGL.RGBA, dwGL.UNSIGNED_BYTE, img ); console.log("cube?", isCubeFace, "target:", uploadTarget, "url:", url, "size:", img.width, img.height); console.log("gl error after texImage2D:", dwGL.getError()); if (clamp) { dwGL.texParameteri(bindTarget, dwGL.TEXTURE_WRAP_S, dwGL.CLAMP_TO_EDGE); dwGL.texParameteri(bindTarget, dwGL.TEXTURE_WRAP_T, dwGL.CLAMP_TO_EDGE); if (isCubeFace) { dwGL.texParameteri(bindTarget, dwGL.TEXTURE_WRAP_R, dwGL.CLAMP_TO_EDGE); } } if (!isCubeFace) { if (!useMips) { dwGL.texParameteri(bindTarget, dwGL.TEXTURE_MIN_FILTER, dwGL.LINEAR); } else { dwGL.texParameteri(bindTarget, dwGL.TEXTURE_MIN_FILTER, dwGL.LINEAR_MIPMAP_LINEAR); dwGL.generateMipmap(bindTarget); } } dwGL.texParameteri(bindTarget, dwGL.TEXTURE_MAG_FILTER, dwGL.LINEAR); if (ext) { const maxAnisotropy = dwGL.getParameter(ext.MAX_TEXTURE_MAX_ANISOTROPY_EXT); dwGL.texParameterf(bindTarget, ext.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(16, maxAnisotropy)); } if (isCubeFace) { textureOwner.loadedFaces++; if (textureOwner.loadedFaces >= 6) { dwGL.bindTexture(dwGL.TEXTURE_CUBE_MAP, tex); dwGL.texParameteri(dwGL.TEXTURE_CUBE_MAP, dwGL.TEXTURE_MIN_FILTER, dwGL.LINEAR); dwGL.texParameteri(dwGL.TEXTURE_CUBE_MAP, dwGL.TEXTURE_MAG_FILTER, dwGL.LINEAR); textureOwner.isReady = true; } } else { textureOwner.isReady = true; } }); img.src = url; return img; } createCheckerboardTexture() { const dwGL = dwGfx.gl(); const ext = dwGL.getExtension('EXT_texture_filter_anisotropic') || dwGL.getExtension('MOZ_EXT_texture_filter_anisotropic') || dwGL.getExtension('WEBKIT_EXT_texture_filter_anisotropic'); this.textureTarget = dwGL.TEXTURE_2D; this.img.drawInfo.texture = dwGL.createTexture(); this.img.ID = dwGfxTexture.textureCount++; dwGL.bindTexture(dwGL.TEXTURE_2D, this.img.drawInfo.texture); if (ext) { const maxAnisotropy = dwGL.getParameter(ext.MAX_TEXTURE_MAX_ANISOTROPY_EXT); dwGL.texParameterf(dwGL.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(16, maxAnisotropy)); } dwGL.texImage2D( dwGL.TEXTURE_2D, 0, dwGL.LUMINANCE, 8, 8, 0, dwGL.LUMINANCE, dwGL.UNSIGNED_BYTE, new Uint8Array([ 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, 0xAA, 0xFF, ]) ); dwGL.generateMipmap(dwGL.TEXTURE_2D); dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_MAG_FILTER, dwGL.NEAREST); } createColorBufferTexture(width, height, useHDR = 0, clamp = 1) { const dwGL = dwGfx.gl(); this.textureTarget = dwGL.TEXTURE_2D; this.img.drawInfo.texture = dwGL.createTexture(); this.img.ID = dwGfxTexture.textureCount++; this.img.drawInfo.width = width; this.img.drawInfo.height = height; dwGL.bindTexture(dwGL.TEXTURE_2D, this.img.drawInfo.texture); const wantHDR = !!useHDR; const canUseHDR = !!dwGfx.extColorBufferFloat; if (wantHDR && canUseHDR) { dwGL.texImage2D( dwGL.TEXTURE_2D, 0, dwGL.RGBA16F, width, height, 0, dwGL.RGBA, dwGL.HALF_FLOAT, null ); } else { dwGL.texImage2D( dwGL.TEXTURE_2D, 0, dwGL.RGBA8, width, height, 0, dwGL.RGBA, dwGL.UNSIGNED_BYTE, null ); } dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_MIN_FILTER, dwGL.LINEAR); dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_MAG_FILTER, dwGL.LINEAR); if (clamp) { dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_WRAP_S, dwGL.CLAMP_TO_EDGE); dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_WRAP_T, dwGL.CLAMP_TO_EDGE); } dwGL.bindTexture(dwGL.TEXTURE_2D, null); this.isReady = true; } createDepthTexture(depthTextureSize = 512, height = null, float32 = false) { const dwGL = dwGfx.gl(); // square by default (shadow map); pass an explicit height for a rectangular // depth texture that matches a non-square scene color buffer (fog depth). const texWidth = depthTextureSize; const texHeight = (height === null) ? depthTextureSize : height; this.textureTarget = dwGL.TEXTURE_2D; this.img.drawInfo.texture = dwGL.createTexture(); this.img.ID = dwGfxTexture.textureCount++; this.img.drawInfo.width = texWidth; this.img.drawInfo.height = texHeight; dwGL.bindTexture(dwGL.TEXTURE_2D, this.img.drawInfo.texture); // float32=false: DEPTH_COMPONENT24 / UNSIGNED_INT -- the shadow-map format, used // only for hardware depth comparison, never sampled as a color value. // float32=true : DEPTH_COMPONENT32F / FLOAT -- the portable *sampleable* depth // format for the fog scene pass. A plain sampler2D read of DEPTH_COMPONENT24 // returns 0 on some drivers (ANGLE/D3D), which made the whole scene read clear // (rawDepth == 0 everywhere) and clipped every fog march; 32F reads back the real // normalized [0,1] window depth. NEAREST-only filtering (set below) suits both. const internalFormat = float32 ? dwGL.DEPTH_COMPONENT32F : dwGL.DEPTH_COMPONENT24; const dataType = float32 ? dwGL.FLOAT : dwGL.UNSIGNED_INT; dwGL.texImage2D( dwGL.TEXTURE_2D, 0, internalFormat, texWidth, texHeight, 0, dwGL.DEPTH_COMPONENT, dataType, null ); dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_MAG_FILTER, dwGL.NEAREST); dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_MIN_FILTER, dwGL.NEAREST); dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_WRAP_S, dwGL.CLAMP_TO_EDGE); dwGL.texParameteri(dwGL.TEXTURE_2D, dwGL.TEXTURE_WRAP_T, dwGL.CLAMP_TO_EDGE); } createCubeMap(faceUrls, useMips = 1, clamp = 1) { this.isReady = false; this.loadedFaces = 0; const dwGL = dwGfx.gl(); this.textureTarget = dwGL.TEXTURE_CUBE_MAP; this.img.ID = dwGfxTexture.textureCount++; this.img.drawInfo.src = "cubemap"; this.img.drawInfo.texture = dwGL.createTexture(); dwGL.bindTexture(dwGL.TEXTURE_CUBE_MAP, this.img.drawInfo.texture); const faces = [ { target: dwGL.TEXTURE_CUBE_MAP_POSITIVE_X, url: faceUrls.px }, { target: dwGL.TEXTURE_CUBE_MAP_NEGATIVE_X, url: faceUrls.nx }, { target: dwGL.TEXTURE_CUBE_MAP_POSITIVE_Y, url: faceUrls.py }, { target: dwGL.TEXTURE_CUBE_MAP_NEGATIVE_Y, url: faceUrls.ny }, { target: dwGL.TEXTURE_CUBE_MAP_POSITIVE_Z, url: faceUrls.pz }, { target: dwGL.TEXTURE_CUBE_MAP_NEGATIVE_Z, url: faceUrls.nz }, ]; /*faces.forEach((face) => { dwGL.texImage2D( face.target, 0, dwGL.RGBA, 1, 1, 0, dwGL.RGBA, dwGL.UNSIGNED_BYTE, new Uint8Array([0, 0, 255, 255]) ); });*/ /* dwGL.texParameteri( dwGL.TEXTURE_CUBE_MAP, dwGL.TEXTURE_MIN_FILTER, useMips ? dwGL.LINEAR_MIPMAP_LINEAR : dwGL.LINEAR );*/ faces.forEach((face) => { this.loadImage( face.url, useMips, dwGfxTexture.TextureType.CUBE_MAP_FACE, clamp, this.img.drawInfo.texture, face.target ); }); dwGL.texParameteri(dwGL.TEXTURE_CUBE_MAP, dwGL.TEXTURE_WRAP_S, dwGL.CLAMP_TO_EDGE); dwGL.texParameteri(dwGL.TEXTURE_CUBE_MAP, dwGL.TEXTURE_WRAP_T, dwGL.CLAMP_TO_EDGE); dwGL.texParameteri(dwGL.TEXTURE_CUBE_MAP, dwGL.TEXTURE_WRAP_R, dwGL.CLAMP_TO_EDGE); dwGL.texParameteri(dwGL.TEXTURE_CUBE_MAP, dwGL.TEXTURE_MAG_FILTER, dwGL.LINEAR); dwGL.texParameteri(dwGL.TEXTURE_CUBE_MAP, dwGL.TEXTURE_MIN_FILTER, dwGL.LINEAR); } apply(stage = 0) { const dwGL = dwGfx.gl(); if (stage == 0) { dwGL.activeTexture(dwGL.TEXTURE0); } else if (stage == 1) { dwGL.activeTexture(dwGL.TEXTURE1); } else if (stage == 2) { dwGL.activeTexture(dwGL.TEXTURE2); } else if (stage == 3) { dwGL.activeTexture(dwGL.TEXTURE3); } dwGL.bindTexture(this.textureTarget || dwGL.TEXTURE_2D, this.img.drawInfo.texture); } };/////////////////////////////////////////////////////////////////////////////////////////////////// // // /////////////////////////////////////////////////////////////////////////////////////////////////// class dwDrawInfo { constructor() { this.init(); } init() { this.x = 0; this.y = 0; this.width = 0; this.height = 0; this.xCenter = 32.0; this.yCenter = 32.0; this.xScale = 1.0; this.yScale = 1.0; this.zScale = 1.0; this.xRotation = 0.0; this.yRotation = 0.0; this.zRotation = 0.0; this.r = 1.0; this.g = 1.0; this.b = 1.0; this.a = 1.0; this.xv = 0.0; this.yv = 0.0; this.zv = 0.0; this.texture = null; this.src = ""; } }; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class dwGfxView { constructor() { this.position = [0.0, 3.3, 10.0]; this.offset = [0.0, 0.0, 0.0]; this.target = [0.0, 3.3, 10.0]; this.viewDirection = [0.0, 0.0, 0.0]; this.w = 2.0; this.h = 1.0; this.perspective = false; this.fieldOfView = 120.0; this.bias = 0.0; this.worldMatrix = 0; this.projectionMatrix = 0; this.projectedTextureMatrix = 0; } setLightView(posX = 0.0, posY = 3.3, posZ = 10.0, targetX = 0.0, targetY = 3.3, targetZ = 0.0, r = 0.0, w = 2, h = 1) { this.position = [posX + this.offset[0], posY + this.offset[1], posZ + this.offset[2]]; this.target = [targetX, targetY, targetZ]; this.r = r; this.w = w; this.h = h; var c = Math.cos(dwGfxMath.DEG_TO_RAD_FACTOR * this.r); var s = Math.sin(dwGfxMath.DEG_TO_RAD_FACTOR * this.r); // first draw from the POV of the light this.worldMatrix = m4.lookAt( this.position, // position [this.target[0], this.target[1], this.target[2]], // target [s, c, 0], // up ); this.projectionMatrix = this.perspective ? m4.perspective( (dwGfxMath.DEG_TO_RAD_FACTOR * this.fieldOfView), this.w / this.h, 0.1, // near 10.0) // far : m4.orthographic( -this.w / 2.0, // left this.w / 2.0, // right -this.h / 2.0, // bottom this.h / 2.0, // top 0.1, // near 10.0); // far this.projectedTextureMatrix = dwGfxMath.createProjectedTextureMatrix(this.projectionMatrix, this.worldMatrix); } setCameraView(w, h) { this.w = w; this.h = h; this.projectionMatrix = dwGfxMath.create3DProjectionMatrix(this.w, this.h); //let posX = this.posX + this.offset[0]; //let posY = this.posY + this.offset[1] + this.angleY; //let posZ = this.posZ + this.offset[2]; this.position = dwGfxMath.getXZCirclePosition(this.zoom, this.angleX, this.offset[1] + this.angleY); this.target[0] = 0.0; this.target[1] = this.position[1]; this.target[2] = 0.0; this.viewDirection = m4.normalize([this.target[0] - this.position[0], this.target[1] - this.position[1], this.target[2] - this.position[2]]); // worldMatrix is also know as the cameraMatrix. this.worldMatrix = m4.lookAt(this.position, this.target, [0.0, 1.0, 0.0]); return this.worldMatrix; } }; /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class dwGfxModel { constructor() { this.position = []; this.normal = []; this.texcoord = []; this.texcoord1 = []; this.indices = []; this.tangent = []; this.arrays = {}; this.productResponse = 0; this.isLoaded = 0; this.isParsed = 0; this.isFinished = 0; this.programInfo = 0; this.bufferInfo = 0; this.VAO = 0; this.fieldOfViewRadians = (dwGfxMath.DEG_TO_RAD_FACTOR * 60); } isReady() { this.loadGPUData(); return this.isFinished; } loadGPUData() { if ((this.isLoaded == 5) && !this.isParsed) { this.parseOBJFile(); this.isParsed = 1; } if (this.isLoaded && this.isParsed && !this.isFinished) { if (this.tangent) { if (this.tangent.length > 0) { this.bufferInfo = twgl.createBufferInfoFromArrays(dwGfx.gl(), {position:this.position, normal:this.normal, texcoord:this.texcoord, indices:this.indices, tangent:{numComponents: 4, data:this.tangent}}); } } else { this.bufferInfo = twgl.createBufferInfoFromArrays(dwGfx.gl(), {position:this.position, normal:this.normal, texcoord:this.texcoord, indices:this.indices}); } this.VAO = twgl.createVAOFromBufferInfo(dwGfx.gl(), this.programInfo, this.bufferInfo); this.isFinished = 1; } } loadOBJFile(objFileName, mainURL) { const model = this; var url = mainURL+objFileName; var http = new XMLHttpRequest(); var params = ''; http.onreadystatechange = function() {//Call a function when the state changes. //alert("Try Decline: " + http.readyState + " " + http.status + " " + http.responseText); if (http.readyState == 4 && http.status == 200) { //alert("Decline: "+http.responseText); model.productResponse = http.responseText; model.isLoaded = 1; } } http.open('POST', url); http.send(params); } parseOBJFile() { let resp = JSON.parse(this.productResponse); if (resp.v) { this.isLoaded++; this.position = resp.v; } if (resp.n) { this.isLoaded++; this.normal = resp.n; } if (resp.uv) { this.isLoaded++; this.texcoord = resp.uv; } if (resp.uv) { this.isLoaded++; this.indices = resp.i; } if (resp.uv) { this.isLoaded++; this.tangent = resp.t; } } loadModel(modelName = "t-shirt-women.obj.json", programInfo = 0, mainURL='https://www.mykindafit.com/dwWebEngine/src/siteTemplate/pages/games/MKF-3D-Designer/assets/models/') { this.programInfo = programInfo; this.loadOBJFile(modelName, mainURL, this); } loadWireFrameBoundingBox(programInfo) { this.bufferInfo = twgl.createBufferInfoFromArrays(dwGfx.gl(), { position: [ -1, -1, -1, 1, -1, -1, -1, 1, -1, 1, 1, -1, -1, -1, 1, 1, -1, 1, -1, 1, 1, 1, 1, 1, ], indices: [ 0, 1, 1, 3, 3, 2, 2, 0, 4, 5, 5, 7, 7, 6, 6, 4, 0, 4, 1, 5, 3, 7, 2, 6, ], }); this.VAO = twgl.createVAOFromBufferInfo(dwGfx.gl(), programInfo, this.bufferInfo); this.isFinished = 1; } drawWireFrameBoundingBox(programInfo, projectionMatrix, cameraMatrix, lightView, tex) { const viewMatrix = m4.inverse(cameraMatrix); dwGfx.gl().useProgram(programInfo.program); // Setup all the needed attributes. dwGfx.gl().bindVertexArray(this.VAO); // scale the cube in Z so it's really long // to represent the texture is being projected to // infinity let mat = m4.multiply(lightView.worldMatrix, m4.identity()); let matT = m4.scale(mat, lightView.w, lightView.h, 0.1); mat = m4.translate(matT, 0.0, 0.0, -2.0); // Set the uniforms we just computed twgl.setUniforms(programInfo, { u_color: [1, 1, 1, 1], u_view: viewMatrix, u_projection: projectionMatrix, u_world: mat, }); // calls gl.drawArrays or gl.drawElements twgl.drawBufferInfo(dwGfx.gl(), this.bufferInfo, dwGfx.gl().LINES); } loadCubeMap(programInfo) { this.bufferInfo = twgl.createBufferInfoFromArrays(dwGfx.gl(), { position: { numComponents: 2, data: [ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ], }, }); this.VAO = twgl.createVAOFromBufferInfo(dwGfx.gl(), programInfo, this.bufferInfo); this.isFinished = 1; } loadQuad(programInfo) { // flat quad, built here (consistent with loadCubeMap: the loader owns its // geometry; the caller only passes programInfo). Units: 1.0 = 1 cm. let arrays = dwGfxModelQuad.build(); this.position = arrays.position; this.normal = arrays.normal; this.texcoord = arrays.texcoord; this.indices = arrays.indices; this.tangent = arrays.tangent; this.bufferInfo = twgl.createBufferInfoFromArrays(dwGfx.gl(), arrays); this.VAO = twgl.createVAOFromBufferInfo(dwGfx.gl(), programInfo, this.bufferInfo); this.isFinished = 1; } loadLightPoint(programInfo) { // a single light point at the origin - the atom for the particle/firework // engine. Position-only, no indices, drawn as gl.POINTS. The point program's // vertex shader writes gl_PointSize; instancing/spawning many is the caller's // job. Units: 1.0 = 1 cm. this.bufferInfo = twgl.createBufferInfoFromArrays(dwGfx.gl(), { position: { numComponents: 3, data: [0.0, 0.0, 0.0], }, }); this.VAO = twgl.createVAOFromBufferInfo(dwGfx.gl(), programInfo, this.bufferInfo); this.isFinished = 1; } drawLightPoint(programInfo, uniforms) { // POINTS-mode draw. The vertex shader is expected to write gl_PointSize; the // fragment shader does the radial glow. Additive blend (gl.ONE, gl.ONE) is the // caller's to enable for the firework/light-point look. dwGfx.gl().bindVertexArray(this.VAO); twgl.setUniforms(programInfo, uniforms); twgl.drawBufferInfo(dwGfx.gl(), this.bufferInfo, dwGfx.gl().POINTS); } renderGL() { } draw() { if (this.isReady()) { dwGfx.gl().bindVertexArray(this.VAO); twgl.drawBufferInfo(dwGfx.gl(), this.bufferInfo); } } drawIt(programInfo, uniforms) { // ------ Draw the Whatever -------- //gl.frontFace(gl.CW); // Setup all the needed attributes. dwGfx.gl().bindVertexArray(this.VAO); // Set the uniforms unique to the cube twgl.setUniforms(programInfo, uniforms); // calls gl.drawArrays or gl.drawElements twgl.drawBufferInfo(dwGfx.gl(), this.bufferInfo); } } /////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // dwGfxGpuTimer.js // // Reusable ASYNCHRONOUS GPU timing utility for the WebGL2 demo, built on EXT_disjoint_timer_query_webgl2. // Its whole reason to exist is to measure real GPU cost (the fog raymarch, the composite, the surfel body) // WITHOUT ever stalling the pipeline: a timing tool that calls gl.finish()/gl.flush() or spin-waits on a // query result changes the very thing it claims to measure and destroys frame pacing. So this class: // - never calls finish / flush / readPixels / getError-loops, and never waits on a result; // - only reads a query once gl.getQueryParameter(q, QUERY_RESULT_AVAILABLE) is true (typically 1-3 frames // after the draw), from poll(), which is called once per frame and returns immediately either way. // // QUERY RING (why): a WebGLQuery cannot be re-begun while its result is still in flight, and allocating one // per frame would leak GPU objects at 60Hz forever. Each named scope therefore owns a small pool created // LAZILY (at most poolSize objects, ever) and recycles them: after warmup, createQuery() is never called // again. If every query in a scope's pool is still in flight (GPU running several frames behind), the frame // is simply NOT timed and counted as dropped -- dropping a sample is always preferable to blocking or to // reporting a number we cannot trust. // // DISJOINT (why the check is not optional): the extension's timer is only valid while the GPU keeps a // stable clock. If the driver changes power state, preempts us, or otherwise loses the timebase it raises // GPU_DISJOINT_EXT, and EVERY timestamp spanning that event is garbage -- often by orders of magnitude. Such // a sample must be DISCARDED, not smoothed, or one clock change poisons the HUD for seconds. The flag is // latched and CLEARED BY READING, so it is read at most once per poll() and applied to everything harvested // in that poll. // // NO NESTING / NO OVERLAP (a hard GL rule, not a style choice): TIME_ELAPSED_EXT allows exactly ONE active // query per context. Nested or interleaved scopes are therefore impossible; a begin() while another scope is // active is REFUSED (and counted in `refused`) rather than silently producing wrong numbers or a GL error. // Callers must use scopes SEQUENTIALLY: begin("raymarch")/end("raymarch") then begin("composite")/... // Anyone who wants a "total" must time a single enclosing scope instead of wrapping the parts. // // FALLBACK HONESTY: when the extension is absent (Firefox, most mobile drivers, any browser that disabled it // for fingerprinting reasons) the class measures CPU SUBMISSION time with performance.now() -- the cost of // issuing the commands, which is NOT the GPU cost and is usually far smaller. That number is labelled // "CPU submit" everywhere it surfaces and mode/available are exposed so a HUD can say so too. Presenting CPU // submission time as GPU time would be a correctness bug, not a cosmetic one, so the string "GPU" is only // ever emitted while mode === "gpu". // // SMOOTHING = ROLLING MEDIAN (not an EMA): frame timings are spiky and occasionally garbage (a disjoint we // failed to catch, a driver hiccup, a first-use shader compile). An EMA folds every outlier permanently into // the displayed value; a median over an odd window simply ignores up to floor(N/2) outliers. Both the raw // latest sample and the smoothed value are exposed, in MILLISECONDS (the extension reports NANOSECONDS). // // CONTEXT LOSS: gl.isContextLost(), or a query object that comes back null/invalid, drops all pending state // and the pool instead of throwing; the pool is then rebuilt lazily on the next begin(), so the timer simply // resumes once the context is back. // // GL is fetched through dwGfx.gl() INSIDE the methods, never cached at construction: this class is routinely // constructed before a context exists (and in headless node tests, where there is no GL at all -- there it // reports mode "cpu" and still works). // // API // new dwGfxGpuTimer({ poolSize: 4, windowSize: 7, enabled: true }) // available -> true when EXT_disjoint_timer_query_webgl2 is usable // mode -> "gpu" | "cpu" (cpu == performance.now() submission time) // extState() -> "ok" | "absent" | "no-gl" // begin(name) -> true if the scope started; false if refused (overlap / no query / lost context) // end(name) -> true if the scope closed // poll() -> number of samples harvested this call; NEVER blocks. Call once per frame. // get(name) -> { latestMs, smoothedMs, samples, dropped } | null // label(name) -> "raymarch 3.12ms (GPU)" | "fog 4.01ms (CPU submit)" // summary() -> one HUD line: mode + extension state + every scope + refused/dropped/disjoint // reset() -> abandon pending queries + clear stats (NOT a per-frame call: it frees the pool) // dispose() -> delete every query object this timer ever created /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 'use strict'; const DW_GPU_TIMER_EXT = "EXT_disjoint_timer_query_webgl2"; // 4 queries per scope covers the usual 1-3 frames of GPU latency with a spare; more just wastes objects, // fewer starts dropping samples on a GPU that runs behind. const DW_GPU_TIMER_POOL = 4; // odd so the median is a real sample and never an average of two; 7 rides out isolated spikes without // feeling laggy at 60Hz (~0.12s of history). const DW_GPU_TIMER_WINDOW = 7; const DW_GPU_TIMER_MAX_POOL = 16; const DW_GPU_TIMER_MAX_WINDOW = 63; const DW_GPU_TIMER_NS_TO_MS = 1.0 / 1000000.0; class dwGfxGpuTimer { constructor(options = {}) { const o = options; this.poolSize = (o.poolSize !== undefined) ? Math.max(1, Math.min(o.poolSize | 0, DW_GPU_TIMER_MAX_POOL)) : DW_GPU_TIMER_POOL; // forced ODD: an even window would make the "median" an average of two samples, which is exactly the // outlier-blending behaviour the median was chosen to avoid. let w = (o.windowSize !== undefined) ? (o.windowSize | 0) : DW_GPU_TIMER_WINDOW; w = Math.max(1, Math.min(w, DW_GPU_TIMER_MAX_WINDOW)); this.windowSize = (w % 2 === 0) ? (w + 1) : w; this.enabled = (o.enabled !== undefined) ? !!o.enabled : true; // extension probe result. undefined => not probed yet; null => probed and absent. Deliberately NOT // resolved here: the context may not exist yet (and must not be cached anyway). this._ext = undefined; this._extProbed = false; this._scopes = new Map(); // name -> scope record (own ring, own smoothing window) this._active = null; // name of the one scope allowed to be open (TIME_ELAPSED cannot nest) this._activeQuery = null; // misuse / health counters, so a caller can SEE that numbers are missing instead of quietly // believing wrong ones. this.refused = 0; // begin() while another scope was already open (or no query free) this.strayEnds = 0; // end(name) that matched no open scope this.disjoint = 0; // samples thrown away because the GPU timebase went disjoint this.dropped = 0; // samples lost for any reason (disjoint + pool exhaustion + context loss) this.contextLosses = 0; this.polls = 0; } // ---- environment ------------------------------------------------------------------------------- // GL is looked up on every use, never stored: the timer outlives context loss/restore and is often // constructed before dwGfx has a context at all. _gl() { try { if (typeof dwGfx === "undefined" || !dwGfx || typeof dwGfx.gl !== "function") { return null; } return dwGfx.gl() || null; } catch (e) { return null; } } _now() { return (typeof performance !== "undefined" && performance && performance.now) ? performance.now() : Date.now(); } // Probe once per context. With NO context we do not latch anything: a timer built before GL exists must // still upgrade itself to "gpu" on its first real frame instead of being stuck in the fallback forever. _extension(gl) { if (this._extProbed) { return this._ext; } if (!gl) { return null; } let ext = null; try { ext = gl.getExtension(DW_GPU_TIMER_EXT) || null; } catch (e) { ext = null; } this._ext = ext; this._extProbed = true; return ext; } get available() { return this._extension(this._gl()) !== null; } // "gpu" means real GPU time. Anything else is CPU submission time and must be labelled as such. get mode() { return this.available ? "gpu" : "cpu"; } extState() { const gl = this._gl(); if (!gl) { return "no-gl"; } return this._extension(gl) ? "ok" : "absent"; } // ---- scopes ------------------------------------------------------------------------------------ _scope(name) { let s = this._scopes.get(name); if (s) { return s; } s = { name: name, pool: [], // every query object created for this scope (bounded by poolSize) free: [], // subset of pool that is idle and re-beginnable pending: [], // FIFO of ended-but-unresolved queries win: [], // rolling window of accepted samples (ms), length <= windowSize scratch: [], // reused sort buffer -> the median costs no allocation latestMs: null, smoothedMs: null, samples: 0, dropped: 0, refused: 0, cpuStart: 0, }; this._scopes.set(name, s); return s; } scopeNames() { return Array.from(this._scopes.keys()); } // Take an idle query, creating one only while the pool is under its cap. null => every query is still in // flight; the caller then skips timing this frame rather than allocating or blocking. _acquire(gl, scope) { if (scope.free.length > 0) { return scope.free.pop(); } if (scope.pool.length >= this.poolSize) { return null; } let q = null; try { q = gl.createQuery(); } catch (e) { q = null; } if (!q) { return null; // context lost mid-create, or a driver refusing to allocate } scope.pool.push(q); return q; } // Delete every query object of a scope and forget all in-flight state. Used for context loss, for an // invalid query, and for reset(): an in-flight query can never be safely re-begun, so abandoning the // object is the only correct way to give up on it. _dropScopeQueries(gl, scope) { if (gl) { for (let i = 0; i < scope.pool.length; i++) { try { gl.deleteQuery(scope.pool[i]); } catch (e) { /* already gone with the context */ } } } this.dropped += scope.pending.length; scope.dropped += scope.pending.length; scope.pool.length = 0; scope.free.length = 0; scope.pending.length = 0; } // Context loss is normal on mobile/tab-switch, so it must degrade rather than throw. All GL objects are // gone; drop them, re-probe the extension (a restored context has its own extension objects), and let // the next begin() rebuild the pool. _handleContextLoss(gl) { this.contextLosses++; this._scopes.forEach((s) => { this._dropScopeQueries(gl, s); }); this._active = null; this._activeQuery = null; this._extProbed = false; this._ext = undefined; } // ---- measurement ------------------------------------------------------------------------------- // Open a scope. Returns false when nothing was started -- disabled, context lost, no free query, or // (the important one) another scope is already open: TIME_ELAPSED_EXT cannot nest or overlap, so the // second begin is REFUSED and counted instead of corrupting both measurements. begin(name) { if (!this.enabled) { return false; } const gl = this._gl(); if (gl && gl.isContextLost && gl.isContextLost()) { this._handleContextLoss(gl); return false; } const scope = this._scope(name); if (this._active !== null) { this.refused++; scope.refused++; return false; } const ext = this._extension(gl); if (!gl || !ext) { // FALLBACK: CPU submission time. Never presented as GPU time (see label()/summary()). scope.cpuStart = this._now(); this._active = name; this._activeQuery = null; return true; } const q = this._acquire(gl, scope); if (!q) { // pool exhausted: the GPU is more than poolSize frames behind. Skip the sample. this.dropped++; scope.dropped++; this.refused++; scope.refused++; return false; } try { gl.beginQuery(ext.TIME_ELAPSED_EXT, q); } catch (e) { scope.free.push(q); this.dropped++; scope.dropped++; return false; } this._active = name; this._activeQuery = q; return true; } // Close the scope opened by begin(name). A mismatched end (because begin was refused, which callers are // expected to ignore) is a counted no-op -- endQuery is never issued without a matching beginQuery. end(name) { if (this._active === null || this._active !== name) { if (this.enabled) { this.strayEnds++; } return false; } const scope = this._scope(name); const gl = this._gl(); const q = this._activeQuery; this._active = null; this._activeQuery = null; if (!q) { // CPU fallback: the measurement is complete right now (submission time), so record it directly. this._record(scope, Math.max(0, this._now() - scope.cpuStart)); return true; } if (gl && gl.isContextLost && gl.isContextLost()) { this._handleContextLoss(gl); return false; } const ext = this._extension(gl); try { gl.endQuery(ext.TIME_ELAPSED_EXT); } catch (e) { scope.free.push(q); this.dropped++; scope.dropped++; return false; } scope.pending.push(q); // resolved later, by poll(), only once the result is available return true; } // Harvest whatever finished. Non-blocking by construction: availability is only ever QUERIED, never // waited for, so a query that is not ready just stays pending. Call once per frame; returns the number // of accepted samples. poll() { this.polls++; const gl = this._gl(); if (!gl) { return 0; // CPU fallback resolves inside end(); nothing to harvest. } if (gl.isContextLost && gl.isContextLost()) { this._handleContextLoss(gl); return 0; } const ext = this._extension(gl); if (!ext) { return 0; } // GPU_DISJOINT_EXT is latched and CLEARED BY READING, so it is read at most once per poll and that // one answer condemns everything harvested in this poll. Reading it per query would consume the flag // on the first query and let the rest of the garbage through. let disjointKnown = false; let disjoint = false; let harvested = 0; const scopes = Array.from(this._scopes.values()); for (let si = 0; si < scopes.length; si++) { const scope = scopes[si]; // FIFO: timer queries complete in submission order, so the first unresolved one ends the scan. while (scope.pending.length > 0) { const q = scope.pending[0]; if (!q) { scope.pending.shift(); this.dropped++; scope.dropped++; continue; } let ready = false; let bad = false; try { ready = !!gl.getQueryParameter(q, gl.QUERY_RESULT_AVAILABLE); } catch (e) { bad = true; } if (bad) { // an invalid query object: give up on this scope's GL state and rebuild it lazily. this._dropScopeQueries(gl, scope); break; } if (!ready) { break; // still in flight -- we do NOT wait for it. } scope.pending.shift(); if (!disjointKnown) { try { disjoint = !!gl.getParameter(ext.GPU_DISJOINT_EXT); } catch (e) { disjoint = false; } disjointKnown = true; } let ns = 0; let readOk = true; try { ns = gl.getQueryParameter(q, gl.QUERY_RESULT); } catch (e) { readOk = false; } scope.free.push(q); // recycled either way -> no allocation after warmup if (disjoint) { // The GPU changed clocks while this spanned the frame: the number is meaningless. this.disjoint++; this.dropped++; scope.dropped++; continue; } if (!readOk || typeof ns !== "number" || !isFinite(ns) || ns < 0) { this.dropped++; scope.dropped++; continue; } this._record(scope, ns * DW_GPU_TIMER_NS_TO_MS); // the extension reports NANOSECONDS harvested++; } } return harvested; } // Accept one sample (ms) into the scope's rolling window and re-derive the median. _record(scope, ms) { scope.latestMs = ms; scope.samples++; scope.win.push(ms); if (scope.win.length > this.windowSize) { scope.win.shift(); } scope.smoothedMs = this._median(scope); } // Median of the current window. Insertion sort into a REUSED scratch array: the window is tiny (<=63) // and this keeps the per-frame allocation count at zero, which matters in a 60Hz render loop. _median(scope) { const n = scope.win.length; if (n === 0) { return null; } const s = scope.scratch; s.length = n; for (let i = 0; i < n; i++) { const v = scope.win[i]; let j = i - 1; while (j >= 0 && s[j] > v) { s[j + 1] = s[j]; j--; } s[j + 1] = v; } // odd window (enforced in the constructor) => a real sample. The even case only occurs while the // window is still filling, where the two central samples are averaged. return (n % 2 === 1) ? s[(n - 1) >> 1] : 0.5 * (s[(n >> 1) - 1] + s[n >> 1]); } // ---- results ----------------------------------------------------------------------------------- get(name) { const s = this._scopes.get(name); if (!s) { return null; } return { latestMs: s.latestMs, smoothedMs: s.smoothedMs, samples: s.samples, dropped: s.dropped, refused: s.refused, pending: s.pending.length, }; } // The suffix is the whole point: "(GPU)" is emitted ONLY in gpu mode. In the fallback the value is CPU // submission time and says so, because a HUD reading "3ms GPU" when the GPU was never measured is worse // than no HUD at all. label(name) { const suffix = (this.mode === "gpu") ? "GPU" : "CPU submit"; const s = this._scopes.get(name); const v = s ? s.smoothedMs : null; const text = (v === null || v === undefined) ? "--" : v.toFixed(2); return name + " " + text + "ms (" + suffix + ")"; } // One HUD line. Leads with the measurement mode + extension state so the numbers are never ambiguous, // and ends with the health counters so refused/dropped samples are visible rather than invisible. summary() { const gpu = (this.mode === "gpu"); const head = (gpu ? "GPU" : "CPU submit") + " [ext:" + this.extState() + " pool" + this.poolSize + " win" + this.windowSize + "]"; const parts = []; this._scopes.forEach((s) => { parts.push(s.name + " " + ((s.smoothedMs === null) ? "--" : s.smoothedMs.toFixed(2)) + "ms"); }); const body = parts.length ? (" " + parts.join(" ")) : " (no scopes)"; return head + body + " | refused " + this.refused + " dropped " + this.dropped + " disjoint " + this.disjoint; } // ---- lifecycle --------------------------------------------------------------------------------- // Abandon everything in flight and clear the statistics. This FREES the query objects (an in-flight // query can never be safely re-begun), so it is a context-loss / user-action call, not a per-frame one: // the pool is rebuilt lazily on the next begin(). reset() { const gl = this._gl(); this._scopes.forEach((s) => { this._dropScopeQueries(gl, s); s.win.length = 0; s.scratch.length = 0; s.latestMs = null; s.smoothedMs = null; s.samples = 0; s.dropped = 0; s.refused = 0; }); this._active = null; this._activeQuery = null; this.refused = 0; this.strayEnds = 0; this.disjoint = 0; this.dropped = 0; this.polls = 0; return this; } // Delete every query object ever created by this timer. Safe to call twice and safe on a lost context. dispose() { const gl = this._gl(); this._scopes.forEach((s) => { if (gl) { for (let i = 0; i < s.pool.length; i++) { try { gl.deleteQuery(s.pool[i]); } catch (e) { /* context already gone */ } } } s.pool.length = 0; s.free.length = 0; s.pending.length = 0; }); this._active = null; this._activeQuery = null; return this; } } // dwGfxPerfConsole -- permanent, lightweight performance isolation console. // // Three independent jobs, deliberately in one file so the demo gains one include and a // handful of call sites instead of a scattered debug layer: // // 1. ALWAYS-ON MINI FPS. A tiny top-right badge fed from requestAnimationFrame deltas. // It survives the full debug HUD being switched off, so there is always a trustworthy // frame-rate reading. DOM is touched at most every 250 ms (4 Hz). // // 2. CATEGORY REGISTRY. Optional-effect categories are registered with GETTER/SETTER // closures onto the demo's real config flags -- this console never becomes a second // source of truth. Shift+Digit hotkeys toggle them; Shift+0 snapshots every category // and forces the core-scene baseline; Shift+9 restores the snapshot exactly. // // 3. CPU-SUBMIT PASS TIMING. performance.now() around a pass measures how long the CPU // took to SUBMIT it. That is NOT GPU time and is labelled "CPU submit" everywhere it // is surfaced. Real GPU time comes from dwGfxGpuTimer (fog only, see the demo). // // No WebGL, no DOM measurement, no layout reads. Safe to construct headless (document // absent) -- the badge is simply skipped and everything else still works, which is what // the node test suite relies on. class dwGfxPerfConsole { constructor(options) { const o = options || {}; // DOM writes and median recomputation are both clamped to this interval. 250 ms is the // 4 Hz ceiling the badge and the full HUD share. this.updateIntervalMs = (o.updateIntervalMs !== undefined) ? +o.updateIntervalMs : 250.0; // Rolling-median window for the CPU-submit timings. Odd so the median is a real sample. this.windowSize = Math.max(1, Math.min(63, (o.windowSize !== undefined) ? (o.windowSize | 0) : 7)); if ((this.windowSize % 2) === 0) { this.windowSize++; } // Master switch for the CPU-submit instrumentation. When false, cpuBegin/cpuEnd are // early-return no-ops: no performance.now() calls at all, not merely a hidden readout. this.timingEnabled = (o.timingEnabled !== undefined) ? !!o.timingEnabled : true; // ---- mini FPS accumulator (requestAnimationFrame based) ---- this.miniVisible = (o.miniVisible !== undefined) ? !!o.miniVisible : true; this.fps = 0.0; this.frameMs = 0.0; this._accumMs = 0.0; this._accumFrames = 0; this._lastFlushMs = 0.0; this._miniText = ""; this._miniEl = null; // ---- category registry ---- // Insertion-ordered map: id -> { id, slot, label, token, get, set, baseline } this._cats = new Map(); this._bySlot = new Map(); this._snapshot = null; // null => no snapshot; otherwise Map(id -> bool) // ---- CPU-submit scopes ---- this._scopes = new Map(); this.onChange = (typeof o.onChange === "function") ? o.onChange : null; if (o.attach !== false) { this._attachMini(); } } // ---- mini FPS badge -------------------------------------------------------------------- // Own DOM node, created once. pointer-events:none is load-bearing: the badge sits over the // canvas and must never swallow a camera drag or a touch-weld. `contain` keeps its reflow // scoped to itself so a text change cannot dirty the page layout. _attachMini() { if (typeof document === "undefined" || !document.body) { return null; } let el = document.getElementById("dwPerfMini"); if (!el) { el = document.createElement("div"); el.id = "dwPerfMini"; el.style.cssText = "position:fixed; right:8px; top:8px; z-index:2147483001;" + "font:11px/1.25 monospace; color:#0f0; background:rgba(0,0,0,0.45);" + "padding:2px 5px; border-radius:3px; white-space:pre;" + "pointer-events:none; contain:content;" + "-webkit-user-select:none; user-select:none;"; document.body.appendChild(el); } this._miniEl = el; el.style.display = this.miniVisible ? "block" : "none"; return el; } setMini(on) { this.miniVisible = !!on; if (this._miniEl) { this._miniEl.style.display = this.miniVisible ? "block" : "none"; } return this.miniVisible; } toggleMini() { return this.setMini(!this.miniVisible); } // Called once per rAF with the raw frame delta. Accumulates only; the DOM write and the // FPS recomputation happen at most every updateIntervalMs. Returns true on a flush frame, // which the demo reuses as the single 4 Hz gate for the full HUD so the two stay in step // and neither can be updated per-frame by accident. frame(rawMs, nowMs) { const now = (nowMs !== undefined) ? nowMs : this._now(); // Reject the first frame and tab-away spikes so the badge cannot show a garbage number. if (rawMs > 0.0 && rawMs < 1000.0) { this._accumMs += rawMs; this._accumFrames++; } if ((now - this._lastFlushMs) < this.updateIntervalMs) { return false; } this._lastFlushMs = now; if (this._accumFrames > 0 && this._accumMs > 0.0) { const ms = this._accumMs / this._accumFrames; const fps = 1000.0 / ms; // EMA over the 4 Hz flushes: already an average of ~15 frames, so a light blend is // enough to stop the last digit flickering without making the reading laggy. this.frameMs = (this.frameMs > 0.0) ? (this.frameMs + (ms - this.frameMs) * 0.35) : ms; this.fps = (this.fps > 0.0) ? (this.fps + (fps - this.fps) * 0.35) : fps; } this._accumMs = 0.0; this._accumFrames = 0; if (this.miniVisible && this._miniEl) { const txt = this.fps.toFixed(0) + " fps " + this.frameMs.toFixed(1) + " ms"; // Skip the DOM write when the rendered text is unchanged. if (txt !== this._miniText) { this._miniText = txt; this._miniEl.textContent = txt; } } return true; } miniText() { return this.fps.toFixed(0) + " fps " + this.frameMs.toFixed(1) + " ms"; } // ---- category registry ----------------------------------------------------------------- // `get`/`set` are closures onto the demo's own flags, so fogConfig/leavesConfig stay the // single source of truth and an existing unshifted key (f, l, x, ...) and a Shift+Digit // hotkey can never disagree. `baseline:false` opts a category out of the Shift+0 sweep. register(id, opts) { const c = opts || {}; if (typeof c.get !== "function" || typeof c.set !== "function") { throw new Error("dwGfxPerfConsole.register(" + id + "): get/set closures are required"); } const slot = (c.slot !== undefined) ? (c.slot | 0) : -1; const cat = { id: id, slot: slot, label: c.label || id, token: c.token || id.toUpperCase(), get: c.get, set: c.set, baseline: (c.baseline !== undefined) ? !!c.baseline : true, }; this._cats.set(id, cat); if (slot >= 0) { this._bySlot.set(slot, cat); } return cat; } has(id) { return this._cats.has(id); } ids() { return Array.from(this._cats.keys()); } slotOf(id) { const c = this._cats.get(id); return c ? c.slot : -1; } isOn(id) { const c = this._cats.get(id); return c ? !!c.get() : false; } set(id, on) { const c = this._cats.get(id); if (!c) { return false; } const v = !!on; c.set(v); if (this.onChange) { this.onChange(id, v); } return v; } toggle(id) { return this.set(id, !this.isOn(id)); } // "on" | "off" | "bypass". "bypass" means the live snapshot recorded this category as ON, // so it is off only because the Shift+0 baseline forced it -- Shift+9 will bring it back. // A category the user turned off themselves reads "off", even under a baseline. state(id) { const c = this._cats.get(id); if (!c) { return "off"; } if (c.get()) { return "on"; } if (this._snapshot && this._snapshot.get(id) === true) { return "bypass"; } return "off"; } // Snapshot EVERY registered category (not only the baseline ones) so restore is exact even // if a category is later opted out of the sweep, then force the baseline ones off. Capturing // twice overwrites the snapshot with the current -- already-baselined -- state, which would // make Shift+9 a no-op, so a repeat capture while a snapshot exists is ignored instead. // That makes capture/restore/capture/restore deterministic. captureBaseline() { if (this._snapshot) { return false; } const snap = new Map(); this._cats.forEach((c, id) => { snap.set(id, !!c.get()); }); this._snapshot = snap; this._cats.forEach((c, id) => { if (c.baseline && c.get()) { this.set(id, false); } }); return true; } restoreBaseline() { if (!this._snapshot) { return false; } const snap = this._snapshot; // Clear first so state() stops reporting "bypass" while the setters run. this._snapshot = null; snap.forEach((wasOn, id) => { if (this._cats.has(id) && this.isOn(id) !== wasOn) { this.set(id, wasOn); } }); return true; } hasSnapshot() { return this._snapshot !== null; } // True when a snapshot exists and every baseline category is currently off, i.e. the // core-scene baseline is actually in force rather than partially edited since Shift+0. atBaseline() { if (!this._snapshot) { return false; } let all = true; this._cats.forEach((c) => { if (c.baseline && c.get()) { all = false; } }); return all; } // Shift + Digit0-9, plus Shift+F for the mini badge. Returns true when the event was consumed, // so the demo can `return` before its unshifted Digit1-5 surfel presets are ever reached. // // Matching on event.code makes the mapping layout-independent: Shift+3 emits '#' on a US // layout but something else elsewhere, and code stays "Digit3" either way. Shift+F is used // for the badge because every unshifted letter a-z is already bound by the demo, while // SHIFTED letters are entirely free (the demo compares event.key against lowercase). handleKey(event) { if (!event || !event.shiftKey) { return false; } const code = event.code || ""; if (code === "KeyF") { this.toggleMini(); return true; } if (code.length !== 6 || code.indexOf("Digit") !== 0) { return false; } const d = code.charCodeAt(5) - 48; if (d < 0 || d > 9) { return false; } if (d === 0) { this.captureBaseline(); return true; } if (d === 9) { this.restoreBaseline(); return true; } const cat = this._bySlot.get(d); if (!cat) { return false; } this.toggle(cat.id); return true; } // ---- CPU-submit pass timing ------------------------------------------------------------ _now() { return (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now(); } _scope(id) { let s = this._scopes.get(id); if (!s) { // Fixed-size ring + reusable scratch: no per-frame allocation once warmed up. s = { id: id, start: 0.0, open: false, ring: new Array(this.windowSize).fill(0.0), scratch: new Array(this.windowSize).fill(0.0), n: 0, w: 0, latestMs: null, dirty: false, medianMs: null, }; this._scopes.set(id, s); } return s; } cpuBegin(id) { if (!this.timingEnabled) { return false; } const s = this._scope(id); s.start = this._now(); s.open = true; return true; } // A stray end (begin was refused because timing is disabled) is a silent no-op, so callers // may pair the two unconditionally without leaking a half-open scope. cpuEnd(id) { if (!this.timingEnabled) { return false; } const s = this._scopes.get(id); if (!s || !s.open) { return false; } s.open = false; const ms = this._now() - s.start; s.latestMs = ms; s.ring[s.w] = ms; s.w = (s.w + 1) % this.windowSize; if (s.n < this.windowSize) { s.n++; } s.dirty = true; return true; } // Median is computed lazily on read, so the sort happens at HUD cadence (4 Hz) rather than // once per frame per scope. cpuMs(id) { const s = this._scopes.get(id); if (!s || s.n === 0) { return null; } if (s.dirty) { const k = s.n; // Insertion sort into the reusable scratch array: no allocation, and k <= 63. for (let i = 0; i < k; i++) { const v = s.ring[i]; let j = i - 1; while (j >= 0 && s.scratch[j] > v) { s.scratch[j + 1] = s.scratch[j]; j--; } s.scratch[j + 1] = v; } s.medianMs = s.scratch[k >> 1]; s.dirty = false; } return s.medianMs; } cpuScopeNames() { return Array.from(this._scopes.keys()); } resetTiming() { this._scopes.forEach((s) => { s.open = false; s.n = 0; s.w = 0; s.latestMs = null; s.medianMs = null; s.dirty = false; }); return this; } // ---- HUD text -------------------------------------------------------------------------- // Compact isolation summary. UPPERCASE token == category on, lowercase == user turned it // off, "~token" == bypassed by the Shift+0 baseline. Leading CORE appears only when the // baseline is genuinely in force. isolationLine() { const parts = []; if (this.atBaseline()) { parts.push("CORE"); } this._cats.forEach((c, id) => { const st = this.state(id); if (st === "on") { parts.push(c.token); } else if (st === "bypass") { parts.push("~" + c.token.toLowerCase()); } else { parts.push(c.token.toLowerCase()); } }); return "iso: " + parts.join(" ") + (this._snapshot ? " [snap: Shift+9 restores]" : ""); } // CPU-submit timings only. The label is explicit because these are submission costs, never // GPU time -- see dwGfxGpuTimer for the one real GPU scope. timingLine() { if (!this.timingEnabled) { return "cpu submit: off"; } const parts = []; this._scopes.forEach((s, id) => { const v = this.cpuMs(id); parts.push(id + " " + ((v === null) ? "--" : v.toFixed(2))); }); return "cpu submit (ms, not GPU): " + (parts.length ? parts.join(" ") : "(no scopes)"); } dispose() { if (this._miniEl && this._miniEl.parentNode) { this._miniEl.parentNode.removeChild(this._miniEl); } this._miniEl = null; this._scopes.clear(); return this; } } if (typeof module !== "undefined" && module.exports) { module.exports = dwGfxPerfConsole; } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // dwGfxSpiritCamera.js // // GESTURE CLASSIFIER for the spirit camera. This class owns NO camera state -- no position, no yaw, // no pitch, no zoom. It converts raw pointer events into a small, mode-neutral INTENT record and // hands that record to whoever asks for it. The camera (dwCameraView's spirit mode) owns the // persistent position/yaw/pitch/velocity and does the dt integration. // // WHY THE SPLIT // An earlier revision of this file drove the camera by shrinking the orbit radius, which faked // "moving toward what you are looking at" by consuming zoom. That is not translation: the camera // still could not leave its origin-locked sphere, could not pitch, and the user's chosen zoom was // destroyed by the gesture. The fix is to give the camera a real position and a real velocity, and // to demote this class to what it is actually good at -- deciding whether a contact was a tap, a // drag, or a hold, and turning pixels into angles. Touch stays completely independent of the // motion model: it cannot reach camera state even by accident, because it is never handed any. // // INTENT RECORD (the entire contract with the camera): // { // tap: bool, one-shot: this contact was an interaction, not a camera gesture // look: [yawDeg, pitchDeg], angular delta accumulated since the last poll // lookPx: [dxPx, dyPx], the same drag in RAW PIXELS, carried alongside so a consumer // with its own historic gains (legacy orbit mode) can reproduce // them exactly instead of inverting this class's conversion // fling: [yawDegPerSec, pitchDegPerSec] | null, one-shot release impulse // glide: -1..1, throttle along the view direction. Positive accelerates forward (touch // hold, or a wand push-forward impulse); NEGATIVE brakes and then // reverses (a wand pull-back impulse). Touch alone never goes negative. // dolly: worldUnits, one-shot discrete step along the view direction (wheel / pinch) // stop: bool, one-shot: kill all velocity (new contact, or cancel) // } // Every field is mode-neutral. The camera decides what a "look" means: in spirit mode it rotates // yaw/pitch; in legacy orbit mode it moves azimuth/height. `glide` is meaningful only in spirit // mode -- orbit mode has nowhere to glide TO -- and orbit mode simply ignores it. That is what // makes the two modes coexist without either one being a special case of the other. // // GESTURE MODEL: // tap -> poll() reports tap:true once; the demo keeps its existing interaction. // drag -> look, with a release fling. // hold -> after holdDelay, glide ramps 0 -> 1 over holdRamp seconds. // hold + drag -> both are simply active at once. No special case, no arbitration. // release -> glide returns to 0 and the fling decays; the camera's own damping eases it out. // // HOUSE RULES: pure scalar arithmetic, no matrices, no GL, no DOM. Node-constructible. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 'use strict'; class dwGfxSpiritCamera { constructor(options = {}) { const o = options || {}; // ---- pixels -> degrees. lookSpeedX deliberately matches the demo's existing inline handler // (angleX += dx * 0.3) so the horizontal feel is unchanged when the intents are consumed by // legacy orbit mode. Pitch has no legacy equivalent -- the old rig could not pitch at all. ---- this.lookSpeedX = o.lookSpeedX !== undefined ? o.lookSpeedX : 0.3; // deg per pixel, yaw this.lookSpeedY = o.lookSpeedY !== undefined ? o.lookSpeedY : 0.22; // deg per pixel, pitch this.invertY = o.invertY !== undefined ? o.invertY : false; // ---- hold-to-glide throttle shaping ---- this.holdGlide = o.holdGlide !== undefined ? o.holdGlide : true; this.holdDelay = o.holdDelay !== undefined ? o.holdDelay : 0.18; // s of contact before the ramp starts this.holdRamp = o.holdRamp !== undefined ? o.holdRamp : 0.55; // s from 0 to full throttle // ---- release fling ---- this.inertia = o.inertia !== undefined ? o.inertia : 1.0; // 0 disables the fling entirely this.flingMax = o.flingMax !== undefined ? o.flingMax : 720.0; // deg/s cap, both axes // ---- tap classification ---- this.tapMaxMs = o.tapMaxMs !== undefined ? o.tapMaxMs : 250.0; this.tapSlopPx = o.tapSlopPx !== undefined ? o.tapSlopPx : 12.0; this.enabled = o.enabled !== undefined ? o.enabled : true; /////////////////////////////////////////////////////////////////////////////////////////// // WAND ASSIST (additive THIRD input mode; TOUCH and legacy ORBIT are untouched) // // The device itself becomes the wand: rotating it supplies LOOK, and a push-forward // acceleration impulse supplies forward glide (pull back brakes, then reverses). It emits // the SAME mode-neutral intent record as a finger does, so the camera needs no new door and // legacy orbit mode is structurally immune -- sensor look is written only into the DEGREES // accumulators, never into lookPx, and orbit mode drives itself from lookPx alone. // // Everything is gated on an explicit state machine, because a motion sensor is not like a // pointer: it fires whether or not anyone asked for it, and on iOS reading it at all // requires permission obtained from a user gesture. // // OFF -> PERMISSION -> CALIBRATING -> ACTIVE // \-> DENIED (unavailable, insecure context, or refused) // // wandSample() REFUSES to accept anything outside CALIBRATING/ACTIVE and counts the // refusals, so "no sensor input before permission or while the mode is disabled" is // enforced here as well as by the host not attaching listeners. Two independent gates. // // No DOM: capability detection is a pure function of a plain env record the host supplies, // and samples arrive as plain numbers. That keeps the house rule intact and makes the whole // mode testable with synthetic events. /////////////////////////////////////////////////////////////////////////////////////////// this.wandMode = false; // master enable; false => not one sample is read this.wandState = "OFF"; // OFF | PERMISSION | CALIBRATING | ACTIVE | DENIED this.wandReason = ""; // why DENIED, for the HUD and the console this.wandInvertX = o.wandInvertX !== undefined ? o.wandInvertX : false; this.wandInvertY = o.wandInvertY !== undefined ? o.wandInvertY : false; // camera degrees per device degree. 1.0 is true 1:1 pointing, which is what makes it read // as a wand rather than as a joystick. this.wandYawSens = o.wandYawSens !== undefined ? o.wandYawSens : 1.0; this.wandPitchSens = o.wandPitchSens !== undefined ? o.wandPitchSens : 1.0; // dead zone + hysteresis. The dead zone kills hand tremor around neutral; the hysteresis // SHRINKS it once the axis has broken out, so tracking stays smooth instead of chattering // on and off at the boundary. Re-arming needs a return to (dead - hyst). this.wandDeadZoneDeg = o.wandDeadZoneDeg !== undefined ? o.wandDeadZoneDeg : 1.5; this.wandHystDeg = o.wandHystDeg !== undefined ? o.wandHystDeg : 0.6; this.wandFilterHz = o.wandFilterHz !== undefined ? o.wandFilterHz : 8.0; // e-folds/s this.wandMaxRateDeg = o.wandMaxRateDeg !== undefined ? o.wandMaxRateDeg: 180.0; // deg/s cap this.wandPitchLimit = o.wandPitchLimit !== undefined ? o.wandPitchLimit: 60.0; // deg either side of neutral // ---- thrust: a genuine IMPULSE detector, not an accelerometer follower ---- // A push is transient: it accelerates, then decelerates as the arm stops. Mapping raw // acceleration to throttle would have the rebound cancel the push exactly. So the first // breach latches a signed throttle and opens a refractory window during which the OPPOSITE // sign is ignored as rebound; the throttle then bleeds off on its own. this.wandThrustAxis = o.wandThrustAxis !== undefined ? o.wandThrustAxis : 2; // 0=x 1=y 2=z this.wandThrustSign = o.wandThrustSign !== undefined ? o.wandThrustSign : -1.0; // +z points at your face this.wandThrustEnter = o.wandThrustEnter !== undefined ? o.wandThrustEnter : 2.2; // m/s^2 to trigger this.wandThrustFullG = o.wandThrustFullG !== undefined ? o.wandThrustFullG : 6.0; // m/s^2 = full throttle this.wandThrustFilterHz = o.wandThrustFilterHz !== undefined ? o.wandThrustFilterHz: 6.0; this.wandImpulseHold = o.wandImpulseHold !== undefined ? o.wandImpulseHold : 0.45; // s refractory this.wandThrustDecay = o.wandThrustDecay !== undefined ? o.wandThrustDecay : 2.5; // e-folds/s // calibration / confidence this.wandCalibSamples = o.wandCalibSamples !== undefined ? o.wandCalibSamples : 8; this.wandJitterRef = o.wandJitterRef !== undefined ? o.wandJitterRef : 3.0; // deg/sample = zero confidence this.wandConfidence = 0.0; // While the wand is ACTIVE a plain touch HOLD must not also push you forward, or every // contact would move the camera by accident. Drag still steers and tap still interacts. this.wandSuppressHold = o.wandSuppressHold !== undefined ? o.wandSuppressHold : true; // optional recorder. Samples are stored as compact 7-number arrays // [tMs, alpha, beta, gamma, ax, ay, az] so a session can be replayed verbatim. this.wandRecord = o.wandRecord !== undefined ? o.wandRecord : false; this.wandRecordMax = o.wandRecordMax !== undefined ? o.wandRecordMax : 600; this._wandNeutralA = 0.0; this._wandNeutralB = 0.0; this._wandCalibN = 0; this._wandCalibBaseA = 0.0; this._wandCalibSumA = 0.0; this._wandCalibSumB = 0.0; this._wandJitterAcc = 0.0; this._wandLastRawA = null; this._wandLastRawB = null; this._wandOffA = 0.0; // filtered offset from neutral, degrees this._wandOffB = 0.0; this._wandPrevA = 0.0; // last EMITTED (post dead-zone) offset this._wandPrevB = 0.0; this._wandArmedA = false; // dead-zone hysteresis latch this._wandArmedB = false; this._wandThrustF = 0.0; // filtered thrust axis this._wandThrottle = 0.0; // signed, -1..1 this._wandImpulseT = 0.0; this._wandImpulseSgn = 0; this._wandLastT = null; // ms this._wandAccepted = 0; this._wandRefused = 0; this._wandSamples = []; // ---- classifier state (gesture only -- nothing here is camera state) ---- this.holding = false; this.holdTime = 0.0; this._lookYaw = 0.0; // accumulated since last poll this._lookPitch = 0.0; this._lookPxX = 0.0; // the same drag in raw pixels this._lookPxY = 0.0; this._dolly = 0.0; this._tap = false; this._stop = false; this._fling = null; this._yawRate = 0.0; // running estimate, deg/s this._pitchRate = 0.0; this._downX = 0.0; this._downY = 0.0; this._downMs = 0.0; this._travelPx = 0.0; this._dragging = false; } // ---- pointer input --------------------------------------------------------------------------- // The demo feeds RAW client pixels; this owns the pixels -> degrees conversion so the touch and // mouse handlers stay identical to each other and neither needs to know any camera constants. onPointerDown(clientX, clientY, timeMs) { this.holding = true; this.holdTime = 0.0; this._downX = clientX; this._downY = clientY; this._downMs = timeMs; this._travelPx = 0.0; this._dragging = false; this._yawRate = 0.0; this._pitchRate = 0.0; // grabbing stops the drift, like catching a spinning globe. this._stop = true; return this; } // dx/dy are pixel deltas since the last move. dt is used only to estimate the release rate. onPointerDrag(dx, dy, dt) { if (!this.holding) { return this; } this._travelPx += Math.abs(dx) + Math.abs(dy); if (this._travelPx > this.tapSlopPx) { this._dragging = true; } const dYaw = dx * this.lookSpeedX; const dPitch = (this.invertY ? dy : -dy) * this.lookSpeedY; this._lookYaw += dYaw; this._lookPitch += dPitch; this._lookPxX += dx; this._lookPxY += dy; // Exponential average rather than the raw last delta, so one jittery final event cannot // throw the camera on release. const step = (dt && dt > 1e-4) ? dt : (1.0 / 60.0); const w = 0.35; this._yawRate = this._yawRate * (1.0 - w) + (dYaw / step) * w; this._pitchRate = this._pitchRate * (1.0 - w) + (dPitch / step) * w; return this; } // wheel / pinch: a discrete step along the view direction, in world units. Positive moves // forward. The camera clamps it against its own bounds; this class does not know them. addDolly(worldUnits) { this._dolly += worldUnits; return this; } // Returns { tap, dragged, heldMs } so the demo can decide whether this contact was an // interaction or a camera gesture. Classifies only -- triggers nothing. onPointerUp(timeMs) { const heldMs = timeMs - this._downMs; const tap = (!this._dragging) && (heldMs <= this.tapMaxMs) && (this._travelPx <= this.tapSlopPx); if (this.inertia && this._dragging) { this._fling = [ this._clampRate(this._yawRate * this.inertia), this._clampRate(this._pitchRate * this.inertia), ]; } else { this._fling = [0.0, 0.0]; } if (tap) { this._tap = true; } this.holding = false; this.holdTime = 0.0; const r = { tap: tap, dragged: this._dragging, heldMs: heldMs }; this._dragging = false; this._travelPx = 0.0; this._yawRate = 0.0; this._pitchRate = 0.0; return r; } // interrupted contact (touchcancel / pointer leaving the canvas): drop the gesture with no fling // and no tap, leaving no stale hold that would keep gliding forever. cancel() { this.holding = false; this.holdTime = 0.0; this._dragging = false; this._travelPx = 0.0; this._yawRate = 0.0; this._pitchRate = 0.0; this._fling = [0.0, 0.0]; this._tap = false; this._stop = true; return this; } // ---- wand assist: capability, permission, calibration ---------------------------------------- // PURE capability test. The host passes what it can see of the environment; this decides. Kept // DOM-free so it is testable, and so a host on a platform with a different sensor API can // supply the same three booleans. // env = { secure, hasOrientation, hasMotion, needsPermission } static wandCapability(env) { const e = env || {}; if (!e.secure) { return { ok: false, needsPermission: false, reason: "insecure context (HTTPS required)" }; } if (!e.hasOrientation) { return { ok: false, needsPermission: false, reason: "no DeviceOrientationEvent" }; } return { ok: true, needsPermission: !!e.needsPermission, reason: e.hasMotion ? "" : "no DeviceMotionEvent (look only, no thrust)", }; } // The host is about to ask the platform for permission. Enters PERMISSION, which accepts no // samples -- so a sensor that is already firing (Android needs no permission but may have been // left attached) still cannot move anything until wandGranted() lands. wandRequest() { this.wandMode = true; this.wandState = "PERMISSION"; this.wandReason = ""; this._wandResetMotion(); return this; } // Permission obtained (or not required). Goes straight to CALIBRATING: the mode is never usable // without a neutral, because "normalized relative to neutral" is the whole idea. wandGranted() { this.wandMode = true; this.wandReason = ""; return this.recenterWand(); } // Refused, or unavailable. Falls all the way back to touch: wandMode false means wandSample() // refuses everything even if the host forgets to detach a listener. wandDenied(reason) { this.wandMode = false; this.wandState = "DENIED"; this.wandReason = reason || "denied"; this._wandResetMotion(); return this; } // Explicit off. Distinct from DENIED so the HUD can tell "you turned it off" from "you cannot // have it", and so toggling back on does not have to clear a denial. wandDisable() { this.wandMode = false; this.wandState = "OFF"; this.wandReason = ""; this._wandResetMotion(); return this; } // Explicit recenter / calibrate. Whatever attitude the device is in NOW becomes neutral, which // is what makes the mode usable lying down, in a car, or in portrait vs landscape. Refuses to // run from OFF/DENIED, so it can never be a back door into sampling. recenterWand() { if (!this.wandMode) { return this; } this.wandState = "CALIBRATING"; this._wandCalibN = 0; this._wandCalibSumA = 0.0; this._wandCalibSumB = 0.0; this._wandJitterAcc = 0.0; this._wandLastRawA = null; this._wandLastRawB = null; this.wandConfidence = 0.0; this._wandResetMotion(); return this; } // Drop every derived quantity. Never touches the neutral: recenterWand owns that. _wandResetMotion() { this._wandOffA = 0.0; this._wandOffB = 0.0; this._wandPrevA = 0.0; this._wandPrevB = 0.0; this._wandArmedA = false; this._wandArmedB = false; this._wandThrustF = 0.0; this._wandThrottle = 0.0; this._wandImpulseT = 0.0; this._wandImpulseSgn = 0; this._wandLastT = null; return this; } // ---- wand assist: the single ingest point ----------------------------------------------------- // // s = { t, alpha, beta, gamma, ax, ay, az } (gamma is accepted and DELIBERATELY NOT USED: // roll must never reach the camera, or holding the phone at a natural angle would bank the // whole world.) Returns true only if the sample was accepted. wandSample(s) { if (!s) { return false; } if (!this.wandMode || (this.wandState !== "CALIBRATING" && this.wandState !== "ACTIVE")) { this._wandRefused++; return false; } const tMs = (typeof s.t === "number") ? s.t : 0.0; let dt = (this._wandLastT === null) ? (1.0 / 60.0) : ((tMs - this._wandLastT) * 0.001); if (!(dt > 1e-4)) { dt = 1.0 / 60.0; } if (dt > 0.1) { dt = 0.1; } this._wandLastT = tMs; const rawA = (typeof s.alpha === "number") ? s.alpha : 0.0; const rawB = (typeof s.beta === "number") ? s.beta : 0.0; if (this.wandRecord) { if (this._wandSamples.length >= this.wandRecordMax) { this._wandSamples.shift(); } this._wandSamples.push([tMs, rawA, rawB, (typeof s.gamma === "number") ? s.gamma : 0.0, (typeof s.ax === "number") ? s.ax : 0.0, (typeof s.ay === "number") ? s.ay : 0.0, (typeof s.az === "number") ? s.az : 0.0]); } this._wandAccepted++; // ---- jitter estimate -> confidence. Sample-to-sample movement while the user is trying to // hold still is the only honest measure of how trustworthy this sensor is. ---- if (this._wandLastRawA !== null) { this._wandJitterAcc += Math.abs(dwGfxSpiritCamera.wandShortestArc(rawA - this._wandLastRawA)) + Math.abs(rawB - this._wandLastRawB); } this._wandLastRawA = rawA; this._wandLastRawB = rawB; if (this.wandState === "CALIBRATING") { if (this._wandCalibN === 0) { this._wandCalibBaseA = rawA; } // accumulate as shortest arcs from the first sample, so calibrating across the 0/360 // seam averages correctly instead of landing at 180. this._wandCalibSumA += dwGfxSpiritCamera.wandShortestArc(rawA - this._wandCalibBaseA); this._wandCalibSumB += rawB; this._wandCalibN++; if (this._wandCalibN >= this.wandCalibSamples) { const n = this._wandCalibN; this._wandNeutralA = this._wandCalibBaseA + this._wandCalibSumA / n; this._wandNeutralB = this._wandCalibSumB / n; const jitter = (n > 1) ? (this._wandJitterAcc / (n - 1)) : this.wandJitterRef; let conf = 1.0 - (jitter / this.wandJitterRef); if (conf < 0.0) { conf = 0.0; } if (conf > 1.0) { conf = 1.0; } this.wandConfidence = conf; // seed the filters AT neutral and the emitted offsets at zero, so the first ACTIVE // sample produces exactly no look. Calibration must not itself move the camera. this._wandOffA = 0.0; this._wandOffB = 0.0; this._wandPrevA = 0.0; this._wandPrevB = 0.0; this.wandState = "ACTIVE"; } return true; } // ---- ACTIVE: normalize relative to neutral, low-pass, dead zone, rate cap ---- let offA = dwGfxSpiritCamera.wandShortestArc(rawA - this._wandNeutralA); let offB = rawB - this._wandNeutralB; if (this.wandInvertX) { offA = -offA; } if (this.wandInvertY) { offB = -offB; } // pitch limit is applied to the DEVICE offset, before any gain, so it is a real limit on how // far the wand can point rather than a clamp the sensitivity can defeat. const pl = this.wandPitchLimit; if (offB > pl) { offB = pl; } if (offB < -pl) { offB = -pl; } const k = 1.0 - Math.exp(-this.wandFilterHz * dt); this._wandOffA += (offA - this._wandOffA) * k; this._wandOffB += (offB - this._wandOffB) * k; const a = this._wandDead(this._wandOffA, "A"); const b = this._wandDead(this._wandOffB, "B"); // absolute pointing, expressed as a DELTA so it composes with drag steering and needs no // change anywhere downstream. let dYaw = (a - this._wandPrevA) * this.wandYawSens; let dPitch = (b - this._wandPrevB) * this.wandPitchSens; this._wandPrevA = a; this._wandPrevB = b; const cap = this.wandMaxRateDeg * dt; if (dYaw > cap) { dYaw = cap; } if (dYaw < -cap) { dYaw = -cap; } if (dPitch > cap) { dPitch = cap; } if (dPitch < -cap) { dPitch = -cap; } // degrees only -- never lookPx. Legacy orbit mode reads lookPx, so it cannot be reached by // the sensor even if someone enables the wand while in orbit mode. this._lookYaw += dYaw; this._lookPitch += dPitch; this._wandThrust(s, dt); return true; } // dead zone with hysteresis. Disarmed: output 0 until |v| breaks the full dead zone. Armed: the // dead zone shrinks by wandHystDeg, and stays shrunk until |v| falls back inside it. Subtracting // the (inner) zone rather than gating means the output is continuous through the threshold, so // arming does not step the camera. _wandDead(v, axis) { const dead = this.wandDeadZoneDeg; const inner = (dead - this.wandHystDeg) > 0.0 ? (dead - this.wandHystDeg) : 0.0; const key = (axis === "A") ? "_wandArmedA" : "_wandArmedB"; const mag = Math.abs(v); if (this[key]) { if (mag < inner) { this[key] = false; return 0.0; } } else { if (mag <= dead) { return 0.0; } this[key] = true; } const out = mag - inner; return (v < 0.0) ? -out : out; } // signed impulse detector. See the constructor note: the refractory window is what stops the // deceleration half of a push from cancelling the push. _wandThrust(s, dt) { const acc = [ (typeof s.ax === "number") ? s.ax : 0.0, (typeof s.ay === "number") ? s.ay : 0.0, (typeof s.az === "number") ? s.az : 0.0, ]; let i = this.wandThrustAxis; if (!(i >= 0 && i <= 2)) { i = 2; } const raw = acc[i] * this.wandThrustSign; const kt = 1.0 - Math.exp(-this.wandThrustFilterHz * dt); this._wandThrustF += (raw - this._wandThrustF) * kt; if (this._wandImpulseT > 0.0) { this._wandImpulseT -= dt; } const f = this._wandThrustF; if (Math.abs(f) > this.wandThrustEnter) { const sgn = (f > 0.0) ? 1 : -1; // inside the window, only the SAME direction may refresh; the opposite sign is the // rebound of the gesture we are already serving. if (!(this._wandImpulseT > 0.0 && sgn !== this._wandImpulseSgn)) { let t = f / this.wandThrustFullG; if (t > 1.0) { t = 1.0; } if (t < -1.0) { t = -1.0; } this._wandThrottle = t; this._wandImpulseSgn = sgn; this._wandImpulseT = this.wandImpulseHold; } } this._wandThrottle *= Math.exp(-this.wandThrustDecay * dt); if (Math.abs(this._wandThrottle) < 1e-3) { this._wandThrottle = 0.0; } return this; } // shortest signed arc in degrees, for the alpha (compass) axis which wraps at 360. static wandShortestArc(d) { let x = d % 360.0; if (x > 180.0) { x -= 360.0; } if (x < -180.0) { x += 360.0; } return x; } // true only when the wand is genuinely driving the camera. wandActive() { return this.wandMode && this.wandState === "ACTIVE"; } // compact HUD/diagnostic record. wandStatus() { return { state: this.wandState, reason: this.wandReason, active: this.wandActive(), yaw: this._wandOffA, pitch: this._wandOffB, thrust: this._wandThrottle, confidence: this.wandConfidence, accepted: this._wandAccepted, refused: this._wandRefused, recorded: this._wandSamples.length, }; } // ---- recorder / replay ----------------------------------------------------------------------- // Samples are [tMs, alpha, beta, gamma, ax, ay, az]. Replaying a recording through wandSample() // reproduces a session exactly, which is also how the tests drive synthetic sensor events. wandSamples() { return this._wandSamples; } wandClearSamples() { this._wandSamples = []; return this; } wandReplay(rows) { if (!rows) { return 0; } let n = 0; for (let i = 0; i < rows.length; i++) { const r = rows[i]; if (!r) { continue; } const okSample = this.wandSample({ t: r[0], alpha: r[1], beta: r[2], gamma: r[3], ax: r[4], ay: r[5], az: r[6], }); if (okSample) { n++; } } return n; } // ---- intent ---------------------------------------------------------------------------------- // Advance the hold timer and return the intent accumulated since the previous poll. One-shot // fields (tap, fling, dolly, stop) are cleared here, so an intent is delivered exactly once even // if the caller polls at a different rate than events arrive. poll(dt) { let h = dt; if (!(h > 0.0)) { h = 0.0; } if (h > 0.1) { h = 0.1; } const wandOn = this.wandActive(); let glide = 0.0; if (this.holding && this.holdGlide) { this.holdTime += h; const over = this.holdTime - this.holdDelay; if (over > 0.0) { glide = (this.holdRamp > 1e-4) ? (over / this.holdRamp) : 1.0; if (glide > 1.0) { glide = 1.0; } } // While the wand is driving, forward motion belongs to the push impulse alone. A touch // that merely rests on the glass must not also fly the camera, or every tap-and-look // would move you. Drag still steers, tap still interacts -- only the hold THROTTLE goes. if (wandOn && this.wandSuppressHold) { glide = 0.0; } } // Thrust is SIGNED: forward glides, backward brakes against the existing velocity and then // reverses. That ordering is not special-cased anywhere -- it is what applying a negative // acceleration along the view direction does. if (wandOn) { glide += this._wandThrottle; if (glide > 1.0) { glide = 1.0; } if (glide < -1.0) { glide = -1.0; } } const intent = { tap: this._tap, look: [this._lookYaw, this._lookPitch], lookPx: [this._lookPxX, this._lookPxY], fling: this._fling, glide: this.enabled ? glide : 0.0, dolly: this._dolly, stop: this._stop, wand: wandOn ? this._wandThrottle : 0.0, // diagnostic only; glide already carries it }; this._tap = false; this._lookYaw = 0.0; this._lookPitch = 0.0; this._lookPxX = 0.0; this._lookPxY = 0.0; this._fling = null; this._dolly = 0.0; this._stop = false; return intent; } // Convenience: poll and hand the intent to a camera that implements applyIntent()/updateSpirit(). // This is the ONLY place this class touches a camera object, and it still only calls the two // public entry points -- it never reads or writes camera state. Returns the intent for the HUD. drive(cameraView, dt) { const intent = this.poll(dt); if (cameraView && typeof cameraView.applyIntent === "function") { cameraView.applyIntent(intent); } if (cameraView && typeof cameraView.updateSpirit === "function") { cameraView.updateSpirit(dt); } return intent; } // compact diagnostic for the HUD. state() { return { holding: this.holding, dragging: this._dragging, holdTime: this.holdTime, enabled: this.enabled, wand: this.wandStatus(), }; } _clampRate(r) { const m = this.flingMax; if (r > m) { return m; } if (r < -m) { return -m; } return r; } } if (typeof module !== "undefined" && module.exports) { module.exports = { dwGfxSpiritCamera: dwGfxSpiritCamera }; } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // dwGfxParticle.js // // Reusable, pooled GPU particle / light-point engine that renders through the // batched GL_POINTS path added to dwGfx (gfx.drawParticles). Nothing here touches // the PBR / bloom / cubemap paths; effects just produce a flat Float32Array batch // that dwGfx uploads and draws additively into the HDR scene buffer. // // Layout (must match dwGfx.createParticleResources / drawParticles): // [px, py, pz, size, r, g, b, a] per point (8 floats, a_position + a_color) // Color rgb is HDR (already multiplied by intensity) so values > 1 bloom. // // Design: // dwGfxParticlePool - SoA typed-array storage + free list, no per-frame GC. // Standard physics/visual fields + a fixed trail ring. // dwGfxParticleEmitter - config-driven spawn (continuous rate + burst) with an // onDeath lifecycle seam (child emitters, e.g. fireworks). // dwGfxFireflyEffect - concrete first effect: wandering fireflies that // occasionally dart and leave a short light-point trail. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 'use strict'; const DW_PARTICLE_STRIDE = 8; // floats per emitted point (matches dwGfx) /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Pooled particle storage. Fixed capacity, reused slots, zero per-frame allocation. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class dwGfxParticlePool { constructor(capacity = 256, trailLength = 8) { this.capacity = capacity; this.trailLength = trailLength; // physics this.px = new Float32Array(capacity); this.py = new Float32Array(capacity); this.pz = new Float32Array(capacity); this.vx = new Float32Array(capacity); this.vy = new Float32Array(capacity); this.vz = new Float32Array(capacity); this.ax = new Float32Array(capacity); this.ay = new Float32Array(capacity); this.az = new Float32Array(capacity); // visual this.r = new Float32Array(capacity); this.g = new Float32Array(capacity); this.b = new Float32Array(capacity); this.size = new Float32Array(capacity); this.intensity = new Float32Array(capacity); this.alpha = new Float32Array(capacity); // lifetime (seconds) this.age = new Float32Array(capacity); this.life = new Float32Array(capacity); this.alive = new Uint8Array(capacity); // per-particle trail ring buffer (light-point history for darts). const tl = capacity * trailLength; this.trailX = new Float32Array(tl); this.trailY = new Float32Array(tl); this.trailZ = new Float32Array(tl); this.trailI = new Float32Array(tl); // recorded intensity at that sample this.trailCount = new Int32Array(capacity); this.trailHead = new Int32Array(capacity); // free list of available slots this.freeList = new Int32Array(capacity); for (let i = 0; i < capacity; i++) { this.freeList[i] = capacity - 1 - i; } this.freeTop = capacity; // number of free slots on the stack } spawn() { if (this.freeTop <= 0) { return -1; } const i = this.freeList[--this.freeTop]; this.alive[i] = 1; this.age[i] = 0.0; this.vx[i] = this.vy[i] = this.vz[i] = 0.0; this.ax[i] = this.ay[i] = this.az[i] = 0.0; this.alpha[i] = 0.0; this.intensity[i] = 1.0; this.trailCount[i] = 0; this.trailHead[i] = 0; return i; } kill(i) { if (!this.alive[i]) { return; } this.alive[i] = 0; this.freeList[this.freeTop++] = i; } pushTrail(i, x, y, z, intensity) { const tl = this.trailLength; const head = this.trailHead[i]; const base = i * tl + head; this.trailX[base] = x; this.trailY[base] = y; this.trailZ[base] = z; this.trailI[base] = intensity; this.trailHead[i] = (head + 1) % tl; if (this.trailCount[i] < tl) { this.trailCount[i]++; } } clearTrail(i) { this.trailCount[i] = 0; this.trailHead[i] = 0; } } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Generic emitter. Continuous emission at `rate` particles/sec plus one-shot // bursts. `config.init(pool, index)` seeds a freshly spawned particle. `onDeath` // is the lifecycle seam a later fireworks effect can use to spawn child emitters. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// class dwGfxParticleEmitter { constructor(pool, config = {}) { this.pool = pool; this.rate = config.rate || 0.0; // continuous particles/sec this.init = config.init || null; // function(pool, i) this.onDeath = config.onDeath || null; // function(pool, i) this.accumulator = 0.0; this.enabled = true; } burst(n) { for (let k = 0; k < n; k++) { const i = this.pool.spawn(); if (i < 0) { break; } if (this.init) { this.init(this.pool, i); } } } // continuous emission; call once per frame from the owning effect's update. update(dt) { if (!this.enabled || this.rate <= 0.0) { return; } this.accumulator += this.rate * dt; while (this.accumulator >= 1.0) { this.accumulator -= 1.0; const i = this.pool.spawn(); if (i < 0) { break; } if (this.init) { this.init(this.pool, i); } } } } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Firefly / lightning-bug effect. // // Per firefly state machine (all times in seconds): // HIDDEN -> invisible pause // FADE_IN -> alpha 0..1 // ACTIVE -> gentle 3D wander; occasional brief dart with a light-point trail; // flicker; counts down its life // FADE_OUT -> alpha 1..0, then respawn (new home + pause) /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const DW_FF_HIDDEN = 0; const DW_FF_FADE_IN = 1; const DW_FF_ACTIVE = 2; const DW_FF_FADE_OUT = 3; class dwGfxFireflyEffect { constructor(gfx, options = {}) { this.gfx = gfx; // ---- tuneable constants (defaults: subtle warm yellow-green fireflies) ---- const o = options; this.count = o.count !== undefined ? o.count : 20; this.center = o.center || [0.0, 2.3, 0.0]; // around the logo/scene this.bounds = o.bounds || [3.0, 2.0, 3.0]; // half-extents of wander box this.trailLength = o.trailLength !== undefined ? o.trailLength : 8; // 6..10 this.glowSize = o.glowSize !== undefined ? o.glowSize : 0.07; // world units this.glowIntensity = o.glowIntensity !== undefined ? o.glowIntensity : 2.6; // HDR this.colorBase = o.colorBase || [0.75, 1.0, 0.35]; // warm yellow-green this.colorJitter = o.colorJitter !== undefined ? o.colorJitter : 0.12; // a minority of fireflies glow pink/magenta instead of yellow-green. this.magentaChance = o.magentaChance !== undefined ? o.magentaChance : 0.28; // fraction magenta this.magentaBase = o.magentaBase || [1.0, 0.30, 0.85]; // pink/magenta this.wanderSpeed = o.wanderSpeed !== undefined ? o.wanderSpeed : 0.35; // drift amplitude this.springK = o.springK !== undefined ? o.springK : 0.8; // pull back to home this.dartChance = o.dartChance !== undefined ? o.dartChance : 0.15; // avg darts/sec/bug this.dartSpeed = o.dartSpeed !== undefined ? o.dartSpeed : 6.0; // impulse speed this.dartDuration = o.dartDuration !== undefined ? o.dartDuration : 0.22; // seconds this.fadeInTime = o.fadeInTime !== undefined ? o.fadeInTime : 1.2; this.fadeOutTime = o.fadeOutTime !== undefined ? o.fadeOutTime : 1.5; this.lifeMin = o.lifeMin !== undefined ? o.lifeMin : 6.0; this.lifeMax = o.lifeMax !== undefined ? o.lifeMax : 12.0; this.pauseMin = o.pauseMin !== undefined ? o.pauseMin : 0.3; this.pauseMax = o.pauseMax !== undefined ? o.pauseMax : 3.5; // -------------------------------------------------------------------------- this.pool = new dwGfxParticlePool(this.count, this.trailLength); // per-firefly extra state (parallel to pool slots). const n = this.count; this.phase = new Int32Array(n); this.phaseTimer = new Float32Array(n); // counts down HIDDEN pause / FADE times this.homeX = new Float32Array(n); this.homeY = new Float32Array(n); this.homeZ = new Float32Array(n); this.seedA = new Float32Array(n); // wander phase seeds this.seedB = new Float32Array(n); this.seedC = new Float32Array(n); this.dartTimer = new Float32Array(n); // remaining dart time (>0 == darting) this.flickerP = new Float32Array(n); // flicker phase this.clock = new Float32Array(n); // per-bug simulation clock // GPU batch: head + trail points per firefly. const maxPoints = this.count * (1 + this.trailLength); this.batch = new Float32Array(maxPoints * DW_PARTICLE_STRIDE); this.batchCount = 0; // emitter drives spawning; init() seeds each firefly slot. this.emitter = new dwGfxParticleEmitter(this.pool, { init: (pool, i) => this._initFirefly(pool, i), }); // spawn the full flight up front (fixed population that respawns in place). this.emitter.burst(this.count); } _rand(a, b) { return a + Math.random() * (b - a); } _initFirefly(pool, i) { // pick a home somewhere in the wander box around the scene center. this.homeX[i] = this.center[0] + this._rand(-this.bounds[0], this.bounds[0]); this.homeY[i] = this.center[1] + this._rand(-this.bounds[1], this.bounds[1]); this.homeZ[i] = this.center[2] + this._rand(-this.bounds[2], this.bounds[2]); pool.px[i] = this.homeX[i]; pool.py[i] = this.homeY[i]; pool.pz[i] = this.homeZ[i]; // pick this firefly's palette once (stable for its whole lifetime): most // are the warm yellow-green base, a minority are pink/magenta. Trails read // pool.r/g/b in buildBatch, so they inherit this color automatically. const base = (Math.random() < this.magentaChance) ? this.magentaBase : this.colorBase; const j = this.colorJitter; pool.r[i] = Math.max(0.0, base[0] + this._rand(-j, j)); pool.g[i] = Math.max(0.0, base[1] + this._rand(-j, j)); pool.b[i] = Math.max(0.0, base[2] + this._rand(-j, j)); pool.size[i] = this.glowSize * this._rand(0.8, 1.3); pool.intensity[i] = this.glowIntensity; pool.alpha[i] = 0.0; pool.life[i] = this._rand(this.lifeMin, this.lifeMax); pool.age[i] = 0.0; pool.clearTrail(i); this.seedA[i] = this._rand(0.0, Math.PI * 2.0); this.seedB[i] = this._rand(0.0, Math.PI * 2.0); this.seedC[i] = this._rand(0.0, Math.PI * 2.0); this.flickerP[i]= this._rand(0.0, Math.PI * 2.0); this.clock[i] = this._rand(0.0, 100.0); this.dartTimer[i] = 0.0; this.phase[i] = DW_FF_HIDDEN; this.phaseTimer[i] = this._rand(this.pauseMin, this.pauseMax); } // simulation only (no GPU work). Call from the demo's update(dt). update(dt) { if (!(dt > 0.0)) { return; } const pool = this.pool; for (let i = 0; i < this.count; i++) { if (!pool.alive[i]) { // fixed population: respawn any freed slot immediately. const s = pool.spawn(); if (s >= 0) { this._initFirefly(pool, s); } continue; } this.clock[i] += dt; const t = this.clock[i]; switch (this.phase[i]) { case DW_FF_HIDDEN: pool.alpha[i] = 0.0; this.phaseTimer[i] -= dt; if (this.phaseTimer[i] <= 0.0) { this.phase[i] = DW_FF_FADE_IN; this.phaseTimer[i] = this.fadeInTime; } break; case DW_FF_FADE_IN: this.phaseTimer[i] -= dt; pool.alpha[i] = 1.0 - Math.max(0.0, this.phaseTimer[i]) / this.fadeInTime; if (this.phaseTimer[i] <= 0.0) { pool.alpha[i] = 1.0; this.phase[i] = DW_FF_ACTIVE; } break; case DW_FF_ACTIVE: pool.age[i] += dt; pool.alpha[i] = 1.0; if (pool.age[i] >= pool.life[i]) { this.phase[i] = DW_FF_FADE_OUT; this.phaseTimer[i] = this.fadeOutTime; } break; case DW_FF_FADE_OUT: this.phaseTimer[i] -= dt; pool.alpha[i] = Math.max(0.0, this.phaseTimer[i]) / this.fadeOutTime; if (this.phaseTimer[i] <= 0.0) { pool.kill(i); continue; } break; } // ---- motion (all phases except pure HIDDEN benefit from drift) ---- // gentle layered-sine wander target offset from home. const w = this.wanderSpeed; const ox = w * (Math.sin(t * 0.7 + this.seedA[i]) + 0.5 * Math.sin(t * 1.7 + this.seedB[i])); const oy = w * 0.6 * (Math.sin(t * 0.9 + this.seedB[i]) + 0.4 * Math.sin(t * 2.3 + this.seedC[i])); const oz = w * (Math.sin(t * 0.6 + this.seedC[i]) + 0.5 * Math.sin(t * 1.3 + this.seedA[i])); const targetX = this.homeX[i] + ox; const targetY = this.homeY[i] + oy; const targetZ = this.homeZ[i] + oz; // occasional dart: brief random impulse (only while active & visible). if (this.phase[i] === DW_FF_ACTIVE && this.dartTimer[i] <= 0.0) { if (Math.random() < this.dartChance * dt) { this.dartTimer[i] = this.dartDuration; const dirX = this._rand(-1.0, 1.0); const dirY = this._rand(-0.6, 0.6); const dirZ = this._rand(-1.0, 1.0); const len = Math.hypot(dirX, dirY, dirZ) || 1.0; pool.vx[i] = (dirX / len) * this.dartSpeed; pool.vy[i] = (dirY / len) * this.dartSpeed; pool.vz[i] = (dirZ / len) * this.dartSpeed; } } if (this.dartTimer[i] > 0.0) { // darting: integrate velocity, record a fading light-point trail. this.dartTimer[i] -= dt; pool.px[i] += pool.vx[i] * dt; pool.py[i] += pool.vy[i] * dt; pool.pz[i] += pool.vz[i] * dt; // dampen the impulse so the dart eases out. const damp = Math.max(0.0, 1.0 - dt * 6.0); pool.vx[i] *= damp; pool.vy[i] *= damp; pool.vz[i] *= damp; pool.pushTrail(i, pool.px[i], pool.py[i], pool.pz[i], pool.intensity[i]); } else { // normal: spring toward the wander target (keeps it bounded). const k = Math.max(0.0, 1.0 - Math.exp(-this.springK * dt)); pool.px[i] += (targetX - pool.px[i]) * k; pool.py[i] += (targetY - pool.py[i]) * k; pool.pz[i] += (targetZ - pool.pz[i]) * k; // let any lingering trail shrink away one sample per frame between darts. if (pool.trailCount[i] > 0) { pool.trailCount[i]--; } } // flicker modulates intensity a little for that firefly shimmer. this.flickerP[i] += dt * 6.0; const flick = 0.75 + 0.25 * Math.sin(this.flickerP[i]) + 0.1 * Math.sin(this.flickerP[i] * 2.7); pool.intensity[i] = this.glowIntensity * flick; } } // build the GPU batch from current state (read-only w.r.t. simulation). // Returns { data, count } for gfx.drawParticles. buildBatch() { const pool = this.pool; const batch = this.batch; const tl = this.trailLength; let o = 0; for (let i = 0; i < this.count; i++) { if (!pool.alive[i] || pool.alpha[i] <= 0.001) { continue; } const inten = pool.intensity[i]; const a = pool.alpha[i]; // bright head point. batch[o + 0] = pool.px[i]; batch[o + 1] = pool.py[i]; batch[o + 2] = pool.pz[i]; batch[o + 3] = pool.size[i]; batch[o + 4] = pool.r[i] * inten; batch[o + 5] = pool.g[i] * inten; batch[o + 6] = pool.b[i] * inten; batch[o + 7] = a; o += DW_PARTICLE_STRIDE; // trail: smaller, dimmer points, most-recent brightest. const tc = pool.trailCount[i]; for (let s = 0; s < tc; s++) { // walk back from the most recent sample. const idx = ((pool.trailHead[i] - 1 - s) % tl + tl) % tl; const base = i * tl + idx; const falloff = 1.0 - (s / tl); // 1 at head .. ~0 at tail const ti = pool.trailI[base] * falloff * 0.6; batch[o + 0] = pool.trailX[base]; batch[o + 1] = pool.trailY[base]; batch[o + 2] = pool.trailZ[base]; batch[o + 3] = pool.size[i] * (0.3 + 0.5 * falloff); batch[o + 4] = pool.r[i] * ti; batch[o + 5] = pool.g[i] * ti; batch[o + 6] = pool.b[i] * ti; batch[o + 7] = a * falloff; o += DW_PARTICLE_STRIDE; } } this.batchCount = o / DW_PARTICLE_STRIDE; return { data: batch, count: this.batchCount }; } } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Touch / tap burst effect. // // A compact, one-shot burst emitted at a point in the world: a bright central // flash, a ring of expanding radial sparks, and a few faster tracer particles that // leave short light-point trails. Reuses the same pooled storage, emitter seam and // batched GL_POINTS light-point shader as the fireflies (no new shader, no PBR // changes). Colors are HDR so the flash and tracers bloom. // // The demo builds a world-space dwRay from the tap location (dwGfxMath.screenToWorldRay) // and calls emitFromRay(ray, touchDepth, strength). This first pass uses a fixed // depth along the ray (no scene raycast); dwCollision is available for future use. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const DW_TOUCH_FLASH = 0; const DW_TOUCH_SPARK = 1; const DW_TOUCH_TRACER = 2; class dwGfxParticleEffectTouch { constructor(gfx, options = {}) { this.gfx = gfx; const o = options; // ---- tuneable constants (restrained but immediately visible + blooms) ---- this.touchDepth = o.touchDepth !== undefined ? o.touchDepth : 6.0; // world units in front of camera this.trailLength = o.trailLength !== undefined ? o.trailLength : 6; // tracer trail samples (4..10) this.burstCount = o.burstCount !== undefined ? o.burstCount : 24; // radial sparks per tap this.tracerCount = o.tracerCount !== undefined ? o.tracerCount : 3; // fast trailing streaks (2..3) this.maxConcurrent = o.maxConcurrent !== undefined ? o.maxConcurrent : 4; // overlapping taps before recycling this.flashSize = o.flashSize !== undefined ? o.flashSize : 0.45; // world units this.flashIntensity = o.flashIntensity !== undefined ? o.flashIntensity: 5.0; // HDR this.flashLife = o.flashLife !== undefined ? o.flashLife : 0.28; // seconds this.sparkSize = o.sparkSize !== undefined ? o.sparkSize : 0.10; this.sparkIntensity = o.sparkIntensity !== undefined ? o.sparkIntensity: 2.8; this.sparkSpeed = o.sparkSpeed !== undefined ? o.sparkSpeed : 3.0; // radial expansion speed this.sparkLifeMin = o.sparkLifeMin !== undefined ? o.sparkLifeMin : 0.45; this.sparkLifeMax = o.sparkLifeMax !== undefined ? o.sparkLifeMax : 0.85; this.tracerSize = o.tracerSize !== undefined ? o.tracerSize : 0.11; this.tracerIntensity= o.tracerIntensity !== undefined ? o.tracerIntensity: 3.4; this.tracerSpeed = o.tracerSpeed !== undefined ? o.tracerSpeed : 8.0; this.tracerLife = o.tracerLife !== undefined ? o.tracerLife : 0.5; this.drag = o.drag !== undefined ? o.drag : 2.2; // per-second velocity damping this.gravity = o.gravity !== undefined ? o.gravity : -1.2; // gentle downward pull (world y/s^2) this.colorJitter = o.colorJitter !== undefined ? o.colorJitter : 0.10; // HDR palette (warm core, cool sparks, magenta tracers to echo the fireflies). this.flashColor = o.flashColor || [1.0, 0.92, 0.65]; this.sparkColor = o.sparkColor || [0.55, 0.85, 1.0]; this.tracerColor = o.tracerColor || [1.0, 0.45, 0.9]; // -------------------------------------------------------------------------- const perTap = 1 + this.burstCount + this.tracerCount; this.capacity = perTap * this.maxConcurrent; this.pool = new dwGfxParticlePool(this.capacity, this.trailLength); // per-slot behavior tag (parallel to pool slots). this.kind = new Int32Array(this.capacity); // emitter is used only for the onDeath/init seam parity with other effects; // bursts here are driven directly by emitFromRay for precise per-kind seeding. this.emitter = new dwGfxParticleEmitter(this.pool, {}); // GPU batch: head + trail points per particle. const maxPoints = this.capacity * (1 + this.trailLength); this.batch = new Float32Array(maxPoints * DW_PARTICLE_STRIDE); this.batchCount = 0; this._pt = [0.0, 0.0, 0.0]; // scratch for ray.pointAt } _rand(a, b) { return a + Math.random() * (b - a); } _setColor(i, base) { const j = this.colorJitter; this.pool.r[i] = Math.max(0.0, base[0] + this._rand(-j, j)); this.pool.g[i] = Math.max(0.0, base[1] + this._rand(-j, j)); this.pool.b[i] = Math.max(0.0, base[2] + this._rand(-j, j)); } // random unit vector (uniform on sphere). _randDir(out) { const z = this._rand(-1.0, 1.0); const a = this._rand(0.0, Math.PI * 2.0); const r = Math.sqrt(Math.max(0.0, 1.0 - z * z)); out[0] = r * Math.cos(a); out[1] = r * Math.sin(a); out[2] = z; return out; } _spawnAt(cx, cy, cz, kind, size, intensity, life, color, speed) { const pool = this.pool; const i = pool.spawn(); if (i < 0) { return -1; } this.kind[i] = kind; pool.px[i] = cx; pool.py[i] = cy; pool.pz[i] = cz; const dir = [0.0, 0.0, 0.0]; if (speed > 0.0) { this._randDir(dir); pool.vx[i] = dir[0] * speed; pool.vy[i] = dir[1] * speed; pool.vz[i] = dir[2] * speed; } else { pool.vx[i] = pool.vy[i] = pool.vz[i] = 0.0; } this._setColor(i, color); pool.size[i] = size; pool.intensity[i] = intensity; pool.alpha[i] = 0.0; pool.age[i] = 0.0; pool.life[i] = life; pool.clearTrail(i); return i; } // emit a burst `touchDepth` world units along `ray` from its origin. `strength` // (default 1) scales spark/tracer speed and intensity for softer/harder taps. emitFromRay(ray, touchDepth, strength) { const td = (touchDepth !== undefined && touchDepth !== null) ? touchDepth : this.touchDepth; const s = (strength !== undefined && strength !== null) ? strength : 1.0; const c = ray.pointAt(td, this._pt); const cx = c[0], cy = c[1], cz = c[2]; // central flash: stationary, brightest, quick decay. this._spawnAt(cx, cy, cz, DW_TOUCH_FLASH, this.flashSize, this.flashIntensity * s, this.flashLife, this.flashColor, 0.0); // expanding radial sparks. for (let k = 0; k < this.burstCount; k++) { const life = this._rand(this.sparkLifeMin, this.sparkLifeMax); const spd = this.sparkSpeed * s * this._rand(0.6, 1.2); this._spawnAt(cx, cy, cz, DW_TOUCH_SPARK, this.sparkSize * this._rand(0.7, 1.2), this.sparkIntensity, life, this.sparkColor, spd); } // fast tracers with trails. for (let k = 0; k < this.tracerCount; k++) { this._spawnAt(cx, cy, cz, DW_TOUCH_TRACER, this.tracerSize, this.tracerIntensity, this.tracerLife, this.tracerColor, this.tracerSpeed * s * this._rand(0.85, 1.15)); } } // simulation only (no GPU work). Call from the demo's update(dt). update(dt) { if (!(dt > 0.0)) { return; } const pool = this.pool; const damp = Math.max(0.0, 1.0 - this.drag * dt); const fadeIn = 0.04; for (let i = 0; i < this.capacity; i++) { if (!pool.alive[i]) { continue; } pool.age[i] += dt; const age = pool.age[i]; const life = pool.life[i]; if (age >= life) { pool.kill(i); continue; } // quick pop-in then linear decay over the remaining life. let a; if (age < fadeIn) { a = age / fadeIn; } else { a = 1.0 - (age - fadeIn) / (life - fadeIn); } pool.alpha[i] = a < 0.0 ? 0.0 : a; // integrate motion (flash is stationary; sparks/tracers move). pool.vy[i] += this.gravity * dt; pool.px[i] += pool.vx[i] * dt; pool.py[i] += pool.vy[i] * dt; pool.pz[i] += pool.vz[i] * dt; pool.vx[i] *= damp; pool.vy[i] *= damp; pool.vz[i] *= damp; if (this.kind[i] === DW_TOUCH_TRACER) { pool.pushTrail(i, pool.px[i], pool.py[i], pool.pz[i], pool.intensity[i]); } } } // build the GPU batch from current state (read-only w.r.t. simulation). // Returns { data, count } for gfx.drawParticles. Same layout as the fireflies. buildBatch() { const pool = this.pool; const batch = this.batch; const tl = this.trailLength; let o = 0; for (let i = 0; i < this.capacity; i++) { if (!pool.alive[i] || pool.alpha[i] <= 0.001) { continue; } const inten = pool.intensity[i]; const a = pool.alpha[i]; // bright head point. batch[o + 0] = pool.px[i]; batch[o + 1] = pool.py[i]; batch[o + 2] = pool.pz[i]; batch[o + 3] = pool.size[i]; batch[o + 4] = pool.r[i] * inten; batch[o + 5] = pool.g[i] * inten; batch[o + 6] = pool.b[i] * inten; batch[o + 7] = a; o += DW_PARTICLE_STRIDE; // trail: smaller, dimmer points, most-recent brightest (tracers only). const tc = pool.trailCount[i]; for (let s = 0; s < tc; s++) { const idx = ((pool.trailHead[i] - 1 - s) % tl + tl) % tl; const base = i * tl + idx; const falloff = 1.0 - (s / tl); const ti = pool.trailI[base] * falloff * 0.6; batch[o + 0] = pool.trailX[base]; batch[o + 1] = pool.trailY[base]; batch[o + 2] = pool.trailZ[base]; batch[o + 3] = pool.size[i] * (0.3 + 0.5 * falloff); batch[o + 4] = pool.r[i] * ti; batch[o + 5] = pool.g[i] * ti; batch[o + 6] = pool.b[i] * ti; batch[o + 7] = a * falloff; o += DW_PARTICLE_STRIDE; } } this.batchCount = o / DW_PARTICLE_STRIDE; return { data: batch, count: this.batchCount }; } } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Welding-torch effect (multi-touch, continuous). // // One persistent instance drives MANY simultaneous welding "heads" (one per finger), // keyed by an arbitrary id (the demo passes Touch.identifier). Each head spews a tight, // energetic, continuously-visible cone of hot sparks at its current world position and // streaks them along the finger's motion (prev->current). Reuses the same pooled storage // and batched GL_POINTS light-point path as the fireflies (no new shader, no PBR change). // Colors are HDR (hot blue-white core + cyan, with a psychedelic magenta minority) so // they bloom. // // Lifecycle (called from the demo's touch handlers): // start(id, x, y, z) - begin a welding head at a world point (touchstart) // move(id, x, y, z) - move a head; sparks streak along prev->current (touchmove) // stop(id) - end a head; its live sparks keep fading naturally (touchend / touchcancel) /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const DW_WELD_SPARK = 0; const DW_WELD_TRACER = 1; class dwGfxParticleEffectWeld { constructor(gfx, options = {}) { this.gfx = gfx; const o = options; // ---- tuneable constants (compact, energetic, continuously visible) ---- this.maxHeads = o.maxHeads !== undefined ? o.maxHeads : 10; // concurrent fingers this.trailLength = o.trailLength !== undefined ? o.trailLength : 4; this.capacity = o.capacity !== undefined ? o.capacity : 512; // shared live-particle cap this.sparkRate = o.sparkRate !== undefined ? o.sparkRate : 110.0; // sparks/sec/head this.tracerRate = o.tracerRate !== undefined ? o.tracerRate : 14.0; // trailing streaks/sec/head this.sparkSize = o.sparkSize !== undefined ? o.sparkSize : 0.07; this.sparkIntensity = o.sparkIntensity !== undefined ? o.sparkIntensity: 3.4; // HDR this.sparkSpeed = o.sparkSpeed !== undefined ? o.sparkSpeed : 3.2; // spit-cone speed this.sparkLifeMin = o.sparkLifeMin !== undefined ? o.sparkLifeMin : 0.18; this.sparkLifeMax = o.sparkLifeMax !== undefined ? o.sparkLifeMax : 0.45; this.tracerSize = o.tracerSize !== undefined ? o.tracerSize : 0.09; this.tracerIntensity= o.tracerIntensity !== undefined ? o.tracerIntensity: 4.0; this.tracerSpeed = o.tracerSpeed !== undefined ? o.tracerSpeed : 5.5; this.tracerLifeMin = o.tracerLifeMin !== undefined ? o.tracerLifeMin : 0.25; this.tracerLifeMax = o.tracerLifeMax !== undefined ? o.tracerLifeMax : 0.5; this.drag = o.drag !== undefined ? o.drag : 3.0; this.gravity = o.gravity !== undefined ? o.gravity : -3.5; // sparks spit then fall this.colorJitter = o.colorJitter !== undefined ? o.colorJitter : 0.12; // HDR palette: hot blue-white core + cyan, with a psychedelic magenta minority. this.coreColor = o.coreColor || [0.85, 0.95, 1.0]; this.cyanColor = o.cyanColor || [0.55, 0.85, 1.0]; this.magentaColor = o.magentaColor || [1.0, 0.30, 0.85]; this.magentaChance = o.magentaChance !== undefined ? o.magentaChance : 0.30; // ----------------------------------------------------------------------- this.pool = new dwGfxParticlePool(this.capacity, this.trailLength); this.kind = new Int32Array(this.capacity); // per-slot behavior tag // active welding heads, keyed by id (Touch.identifier). "or equivalent" per spec. this.heads = new Map(); const maxPoints = this.capacity * (1 + this.trailLength); this.batch = new Float32Array(maxPoints * DW_PARTICLE_STRIDE); this.batchCount = 0; } _rand(a, b) { return a + Math.random() * (b - a); } _pickColor() { if (Math.random() < this.magentaChance) { return this.magentaColor; } return (Math.random() < 0.5) ? this.coreColor : this.cyanColor; } _setColor(i, base) { const j = this.colorJitter; this.pool.r[i] = Math.max(0.0, base[0] + this._rand(-j, j)); this.pool.g[i] = Math.max(0.0, base[1] + this._rand(-j, j)); this.pool.b[i] = Math.max(0.0, base[2] + this._rand(-j, j)); } _randDir(out) { const z = this._rand(-1.0, 1.0); const a = this._rand(0.0, Math.PI * 2.0); const r = Math.sqrt(Math.max(0.0, 1.0 - z * z)); out[0] = r * Math.cos(a); out[1] = r * Math.sin(a); out[2] = z; return out; } // begin a welding head for a finger. No-op growth past maxHeads (existing heads keep going). start(id, x, y, z) { if (!this.heads.has(id) && this.heads.size >= this.maxHeads) { return; } this.heads.set(id, { x: x, y: y, z: z, px: x, py: y, pz: z, sparkAcc: 0.0, tracerAcc: 0.0 }); } // update a head's current world position (previous position is advanced in update()). move(id, x, y, z) { const h = this.heads.get(id); if (!h) { return; } h.x = x; h.y = y; h.z = z; } // end a head. Its already-spawned sparks keep fading naturally via the shared pool. stop(id) { this.heads.delete(id); } stopAll() { this.heads.clear(); } _spawn(kind, x, y, z, base, size, intensity, life, speed) { const pool = this.pool; const i = pool.spawn(); if (i < 0) { return -1; // shared cap reached: self-limits, no overflow. } this.kind[i] = kind; pool.px[i] = x; pool.py[i] = y; pool.pz[i] = z; const dir = [0.0, 0.0, 0.0]; this._randDir(dir); // bias the spit slightly upward so it arcs like a torch rather than a sphere. pool.vx[i] = dir[0] * speed; pool.vy[i] = dir[1] * speed + this._rand(0.5, 1.5); pool.vz[i] = dir[2] * speed; this._setColor(i, base); pool.size[i] = size; pool.intensity[i] = intensity; pool.alpha[i] = 0.0; pool.age[i] = 0.0; pool.life[i] = life; pool.clearTrail(i); return i; } // simulation only (no GPU work). Call from the demo's update(dt). update(dt) { if (!(dt > 0.0)) { return; } const pool = this.pool; // ---- emit from each active head, streaking spawns along prev->current motion ---- this.heads.forEach((h) => { h.sparkAcc += this.sparkRate * dt; h.tracerAcc += this.tracerRate * dt; let ns = Math.floor(h.sparkAcc); h.sparkAcc -= ns; for (let k = 0; k < ns; k++) { const t = Math.random(); const x = h.px + (h.x - h.px) * t; const y = h.py + (h.y - h.py) * t; const z = h.pz + (h.z - h.pz) * t; this._spawn(DW_WELD_SPARK, x, y, z, this._pickColor(), this.sparkSize * this._rand(0.7, 1.3), this.sparkIntensity, this._rand(this.sparkLifeMin, this.sparkLifeMax), this.sparkSpeed * this._rand(0.6, 1.3)); } let nt = Math.floor(h.tracerAcc); h.tracerAcc -= nt; for (let k = 0; k < nt; k++) { const t = Math.random(); const x = h.px + (h.x - h.px) * t; const y = h.py + (h.y - h.py) * t; const z = h.pz + (h.z - h.pz) * t; this._spawn(DW_WELD_TRACER, x, y, z, this._pickColor(), this.tracerSize, this.tracerIntensity, this._rand(this.tracerLifeMin, this.tracerLifeMax), this.tracerSpeed * this._rand(0.7, 1.2)); } h.px = h.x; h.py = h.y; h.pz = h.z; }); // ---- advance all live particles ---- const damp = Math.max(0.0, 1.0 - this.drag * dt); const fadeIn = 0.03; for (let i = 0; i < this.capacity; i++) { if (!pool.alive[i]) { continue; } pool.age[i] += dt; const age = pool.age[i]; const life = pool.life[i]; if (age >= life) { pool.kill(i); continue; } let a; if (age < fadeIn) { a = age / fadeIn; } else { a = 1.0 - (age - fadeIn) / (life - fadeIn); } pool.alpha[i] = a < 0.0 ? 0.0 : a; pool.vy[i] += this.gravity * dt; pool.px[i] += pool.vx[i] * dt; pool.py[i] += pool.vy[i] * dt; pool.pz[i] += pool.vz[i] * dt; pool.vx[i] *= damp; pool.vy[i] *= damp; pool.vz[i] *= damp; if (this.kind[i] === DW_WELD_TRACER) { pool.pushTrail(i, pool.px[i], pool.py[i], pool.pz[i], pool.intensity[i]); } } } // build the GPU batch from current state (read-only). Same layout as the fireflies. buildBatch() { const pool = this.pool; const batch = this.batch; const tl = this.trailLength; let o = 0; for (let i = 0; i < this.capacity; i++) { if (!pool.alive[i] || pool.alpha[i] <= 0.001) { continue; } const inten = pool.intensity[i]; const a = pool.alpha[i]; batch[o + 0] = pool.px[i]; batch[o + 1] = pool.py[i]; batch[o + 2] = pool.pz[i]; batch[o + 3] = pool.size[i]; batch[o + 4] = pool.r[i] * inten; batch[o + 5] = pool.g[i] * inten; batch[o + 6] = pool.b[i] * inten; batch[o + 7] = a; o += DW_PARTICLE_STRIDE; const tc = pool.trailCount[i]; for (let s = 0; s < tc; s++) { const idx = ((pool.trailHead[i] - 1 - s) % tl + tl) % tl; const base = i * tl + idx; const falloff = 1.0 - (s / tl); const ti = pool.trailI[base] * falloff * 0.6; batch[o + 0] = pool.trailX[base]; batch[o + 1] = pool.trailY[base]; batch[o + 2] = pool.trailZ[base]; batch[o + 3] = pool.size[i] * (0.3 + 0.5 * falloff); batch[o + 4] = pool.r[i] * ti; batch[o + 5] = pool.g[i] * ti; batch[o + 6] = pool.b[i] * ti; batch[o + 7] = a * falloff; o += DW_PARTICLE_STRIDE; } } this.batchCount = o / DW_PARTICLE_STRIDE; return { data: batch, count: this.batchCount }; } } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Firework burst effect (multi-touch release). // // One persistent instance handles several overlapping exploding bursts. explodeAt(x,y,z) // (or explodeFromRay) throws a bright central flash plus a WIDE radial shell of sparks and // a handful of fast tracers with light-point trails — deliberately wider, faster and // shorter-lived than the welding stream so a finger lift reads as a clear "pop". Reuses the // same pooled storage / batched GL_POINTS path (no new shader). Psychedelic HDR palette // (magenta/pink + cyan + acid-green accents) so it blooms. /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// const DW_FW_FLASH = 0; const DW_FW_SPARK = 1; const DW_FW_TRACER = 2; class dwGfxParticleEffectFirework { constructor(gfx, options = {}) { this.gfx = gfx; const o = options; // ---- tuneable constants (wide, short-lived, clearly visible) ---- this.trailLength = o.trailLength !== undefined ? o.trailLength : 6; this.burstCount = o.burstCount !== undefined ? o.burstCount : 60; // radial sparks per pop this.tracerCount = o.tracerCount !== undefined ? o.tracerCount : 10; // fast trailing streaks this.maxConcurrent = o.maxConcurrent !== undefined ? o.maxConcurrent : 6; // overlapping pops this.flashSize = o.flashSize !== undefined ? o.flashSize : 0.7; this.flashIntensity = o.flashIntensity !== undefined ? o.flashIntensity: 6.0; this.flashLife = o.flashLife !== undefined ? o.flashLife : 0.30; this.sparkSize = o.sparkSize !== undefined ? o.sparkSize : 0.12; this.sparkIntensity = o.sparkIntensity !== undefined ? o.sparkIntensity: 3.0; this.sparkSpeed = o.sparkSpeed !== undefined ? o.sparkSpeed : 7.0; // wide expansion this.sparkLifeMin = o.sparkLifeMin !== undefined ? o.sparkLifeMin : 0.6; this.sparkLifeMax = o.sparkLifeMax !== undefined ? o.sparkLifeMax : 1.2; this.tracerSize = o.tracerSize !== undefined ? o.tracerSize : 0.13; this.tracerIntensity= o.tracerIntensity !== undefined ? o.tracerIntensity: 3.6; this.tracerSpeed = o.tracerSpeed !== undefined ? o.tracerSpeed : 11.0; this.tracerLife = o.tracerLife !== undefined ? o.tracerLife : 0.8; this.drag = o.drag !== undefined ? o.drag : 1.6; this.gravity = o.gravity !== undefined ? o.gravity : -2.2; this.colorJitter = o.colorJitter !== undefined ? o.colorJitter : 0.12; // HDR psychedelic palette: warm core flash + magenta/pink, cyan and acid-green sparks. this.flashColor = o.flashColor || [1.0, 0.90, 0.70]; this.paletteA = o.paletteA || [1.0, 0.30, 0.85]; // magenta / pink this.paletteB = o.paletteB || [0.55, 0.85, 1.0]; // cyan this.paletteC = o.paletteC || [0.70, 1.0, 0.40]; // acid-green accent // ----------------------------------------------------------------- const perBurst = 1 + this.burstCount + this.tracerCount; this.capacity = perBurst * this.maxConcurrent; this.pool = new dwGfxParticlePool(this.capacity, this.trailLength); this.kind = new Int32Array(this.capacity); const maxPoints = this.capacity * (1 + this.trailLength); this.batch = new Float32Array(maxPoints * DW_PARTICLE_STRIDE); this.batchCount = 0; this._pt = [0.0, 0.0, 0.0]; // scratch for ray.pointAt } _rand(a, b) { return a + Math.random() * (b - a); } _setColor(i, base) { const j = this.colorJitter; this.pool.r[i] = Math.max(0.0, base[0] + this._rand(-j, j)); this.pool.g[i] = Math.max(0.0, base[1] + this._rand(-j, j)); this.pool.b[i] = Math.max(0.0, base[2] + this._rand(-j, j)); } _randDir(out) { const z = this._rand(-1.0, 1.0); const a = this._rand(0.0, Math.PI * 2.0); const r = Math.sqrt(Math.max(0.0, 1.0 - z * z)); out[0] = r * Math.cos(a); out[1] = r * Math.sin(a); out[2] = z; return out; } _pickPalette() { const r = Math.random(); if (r < 0.5) { return this.paletteA; } if (r < 0.8) { return this.paletteB; } return this.paletteC; } _spawnAt(cx, cy, cz, kind, size, intensity, life, color, speed) { const pool = this.pool; const i = pool.spawn(); if (i < 0) { return -1; } this.kind[i] = kind; pool.px[i] = cx; pool.py[i] = cy; pool.pz[i] = cz; const dir = [0.0, 0.0, 0.0]; if (speed > 0.0) { this._randDir(dir); pool.vx[i] = dir[0] * speed; pool.vy[i] = dir[1] * speed; pool.vz[i] = dir[2] * speed; } else { pool.vx[i] = pool.vy[i] = pool.vz[i] = 0.0; } this._setColor(i, color); pool.size[i] = size; pool.intensity[i] = intensity; pool.alpha[i] = 0.0; pool.age[i] = 0.0; pool.life[i] = life; pool.clearTrail(i); return i; } // explode at a world point. `strength` (default 1) scales spark/tracer speed + flash. explodeAt(x, y, z, strength) { const s = (strength !== undefined && strength !== null) ? strength : 1.0; // bright central flash: stationary, brightest, quick decay. this._spawnAt(x, y, z, DW_FW_FLASH, this.flashSize, this.flashIntensity * s, this.flashLife, this.flashColor, 0.0); // wide radial shell of sparks. for (let k = 0; k < this.burstCount; k++) { const life = this._rand(this.sparkLifeMin, this.sparkLifeMax); const spd = this.sparkSpeed * s * this._rand(0.5, 1.3); this._spawnAt(x, y, z, DW_FW_SPARK, this.sparkSize * this._rand(0.7, 1.3), this.sparkIntensity, life, this._pickPalette(), spd); } // fast tracers with trails. for (let k = 0; k < this.tracerCount; k++) { this._spawnAt(x, y, z, DW_FW_TRACER, this.tracerSize, this.tracerIntensity, this.tracerLife, this._pickPalette(), this.tracerSpeed * s * this._rand(0.8, 1.2)); } } // convenience: explode at a fixed depth along a world-space ray. explodeFromRay(ray, depth, strength) { const c = ray.pointAt(depth, this._pt); this.explodeAt(c[0], c[1], c[2], strength); } // simulation only (no GPU work). Call from the demo's update(dt). update(dt) { if (!(dt > 0.0)) { return; } const pool = this.pool; const damp = Math.max(0.0, 1.0 - this.drag * dt); const fadeIn = 0.04; for (let i = 0; i < this.capacity; i++) { if (!pool.alive[i]) { continue; } pool.age[i] += dt; const age = pool.age[i]; const life = pool.life[i]; if (age >= life) { pool.kill(i); continue; } let a; if (age < fadeIn) { a = age / fadeIn; } else { a = 1.0 - (age - fadeIn) / (life - fadeIn); } pool.alpha[i] = a < 0.0 ? 0.0 : a; pool.vy[i] += this.gravity * dt; pool.px[i] += pool.vx[i] * dt; pool.py[i] += pool.vy[i] * dt; pool.pz[i] += pool.vz[i] * dt; pool.vx[i] *= damp; pool.vy[i] *= damp; pool.vz[i] *= damp; if (this.kind[i] === DW_FW_TRACER) { pool.pushTrail(i, pool.px[i], pool.py[i], pool.pz[i], pool.intensity[i]); } } } // build the GPU batch from current state (read-only). Same layout as the fireflies. buildBatch() { const pool = this.pool; const batch = this.batch; const tl = this.trailLength; let o = 0; for (let i = 0; i < this.capacity; i++) { if (!pool.alive[i] || pool.alpha[i] <= 0.001) { continue; } const inten = pool.intensity[i]; const a = pool.alpha[i]; batch[o + 0] = pool.px[i]; batch[o + 1] = pool.py[i]; batch[o + 2] = pool.pz[i]; batch[o + 3] = pool.size[i]; batch[o + 4] = pool.r[i] * inten; batch[o + 5] = pool.g[i] * inten; batch[o + 6] = pool.b[i] * inten; batch[o + 7] = a; o += DW_PARTICLE_STRIDE; const tc = pool.trailCount[i]; for (let s = 0; s < tc; s++) { const idx = ((pool.trailHead[i] - 1 - s) % tl + tl) % tl; const base = i * tl + idx; const falloff = 1.0 - (s / tl); const ti = pool.trailI[base] * falloff * 0.6; batch[o + 0] = pool.trailX[base]; batch[o + 1] = pool.trailY[base]; batch[o + 2] = pool.trailZ[base]; batch[o + 3] = pool.size[i] * (0.3 + 0.5 * falloff); batch[o + 4] = pool.r[i] * ti; batch[o + 5] = pool.g[i] * ti; batch[o + 6] = pool.b[i] * ti; batch[o + 7] = a * falloff; o += DW_PARTICLE_STRIDE; } } this.batchCount = o / DW_PARTICLE_STRIDE; return { data: batch, count: this.batchCount }; } } "use strict"; // dwGfxFallingLeaves -- a reusable, configurable falling-leaves particle effect for the DW // WebGL2 engine. Self-contained: it builds its OWN instanced program, its OWN base-quad + a // dynamic per-instance buffer, and draws two-sided procedural leaf quads with rigorous GL // state save/restore (mirroring dwGfxVolumetricFog's discipline). It is engine-agnostic beyond // two hooks it shares with the rest of the demo: dwGfx.gl() for the WebGL2 context and the // global m4 for matrix math (m4 ONLY, per project rule). StandardPBR and the fog are untouched. // // WHY A NEW FILE (architecture decision): the engine's other particle systems // (dwGfxFireflyEffect / dwGfxParticleEffectWeld / dwGfxParticleEffectFirework) live only in the // REMOTE dwGfx.php concatenation and expose an ADDITIVE light-point path (gfx.drawParticles), // which cannot render two-sided textured/silhouetted leaf quads with tumbling orientation and // alpha over-compositing. Leaves therefore need their own quad program. Following the proven // local-override model of dwGfxVolumetricFog.js, this ships as a NEW local file the demo //