UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

1,098 lines (857 loc) • 31.2 kB
import { assert } from "../../../../core/assert.js"; import { Base64 } from "../../../../core/binary/base64/Base64.js"; import { compute_typed_array_constructor_from_data_type } from "../../../../core/binary/type/DataType2TypedArrayConstructorMapping.js"; import { array_buffer_hash } from "../../../../core/collection/array/typed/array_buffer_hash.js"; import { compute_binary_data_type_from_typed_array } from "../../../../core/collection/array/typed/compute_binary_data_type_from_typed_array.js"; import { is_typed_array_equals } from "../../../../core/collection/array/typed/is_typed_array_equals.js"; import { isTypedArray } from "../../../../core/collection/array/typed/isTypedArray.js"; import { typedArrayConstructorByInstance } from "../../../../core/collection/array/typed/typedArrayConstructorByInstance.js"; import { clamp } from "../../../../core/math/clamp.js"; import { lerp } from "../../../../core/math/lerp.js"; import { max2 } from "../../../../core/math/max2.js"; import { min2 } from "../../../../core/math/min2.js"; import { interpolate_bicubic } from "../../../../core/math/spline/interpolate_bicubic.js"; import { computeHashFloat } from "../../../../core/primitives/numbers/computeHashFloat.js"; /** * Data Texture class, providing an abstraction around 2d numerical arrays, mostly for sampling purposes * Inspired by OpenGL's glsl sampler2d API * @class * @author Alex Goldring * @copyright Company Named Limited (c) 2025 */ export class Sampler2D { /** * * @param {ArrayLike<number>|number[]|Uint8ClampedArray|Uint8Array|Uint16Array|Uint32Array|Int8Array|Int16Array|Int32Array|Float32Array|Float64Array} data * @param {number} [itemSize] * @param {number} [width] * @param {number} [height] * @constructor */ constructor(data = [], itemSize = 1, width = 0, height = 0) { if (!Number.isInteger(itemSize) || itemSize < 0) { throw new Error(`itemSize must be a non-negative integer, instead was ${itemSize}`); } if (!Number.isInteger(width) || width < 0) { throw new Error(`width must be a non-negative integer, instead was ${width}`); } if (!Number.isInteger(height) || width < 0) { throw new Error(`height must be a non-negative integer, instead was ${height}`); } if (data === undefined) { throw new Error("data was undefined"); } if (data.length < width * height * itemSize) { throw new Error(`Buffer underflow, data.length(=${data.length}) is too small. Expected at least ${width * height * itemSize}`); } /** * * @type {number} */ this.width = width; /** * * @type {number} */ this.height = height; /** * Number of channels * @type {number} */ this.itemSize = itemSize; /** * * @type {number[]|Uint8ClampedArray|Uint8Array|Uint16Array|Uint32Array|Int8Array|Int16Array|Int32Array|Float32Array|Float64Array} */ this.data = data; /** * Used to tracking changes * @type {number} */ this.version = 0; } /** * * @param {number} u * @param {number} v * @param {number[]} result */ sampleCatmullRomUV(u, v, result) { const itemSize = this.itemSize; for (let i = 0; i < itemSize; i++) { result[i] = this.sampleChannelCatmullRomUV(u, v, i); } } /** * * @param {number} u * @param {number} v * @param {number} channel * @returns {number} */ sampleChannelCatmullRomUV(u, v, channel) { const x = u * this.width - 0.5; const y = v * this.height - 0.5; return this.sampleChannelCatmullRom(x, y, channel); } /** * * @see https://gist.github.com/TheRealMJP/c83b8c0f46b63f3a88a5986f4fa982b1 * @param {number} x * @param {number} y * @param {number} channel * @returns {number} */ sampleChannelCatmullRom(x, y, channel) { // We're going to sample a 4x4 grid of texels surrounding the target UV coordinate. We'll do this by rounding // down the sample location to get the exact center of our "starting" texel. The starting texel will be at // location [1, 1] in the grid, where [0, 0] is the top-left corner. const texPos1_x = Math.floor(x); const texPos1_y = Math.floor(y); // Compute the fractional offset from our starting texel to our original sample location, which we'll // feed into the Catmull-Rom spline function to get our filter weights. const f_x = x - texPos1_x; const f_y = y - texPos1_y; // Compute the Catmull-Rom weights using the fractional offset that we calculated earlier. // These equations are pre-expanded based on our knowledge of where the texels will be located, // which lets us avoid having to evaluate a piece-wise function. const w0_x = f_x * (-0.5 + f_x * (1.0 - 0.5 * f_x)); const w0_y = f_y * (-0.5 + f_y * (1.0 - 0.5 * f_y)); const w1_x = 1.0 + f_x * f_x * (-2.5 + 1.5 * f_x); const w1_y = 1.0 + f_y * f_y * (-2.5 + 1.5 * f_y); const w2_x = f_x * (0.5 + f_x * (2.0 - 1.5 * f_x)); const w2_y = f_y * (0.5 + f_y * (2.0 - 1.5 * f_y)); const w3_x = f_x * f_x * (-0.5 + 0.5 * f_x); const w3_y = f_y * f_y * (-0.5 + 0.5 * f_y); // Work out weighting factors and sampling offsets that will let us use bilinear filtering to // simultaneously evaluate the middle 2 samples from the 4x4 grid. const w12_x = w1_x + w2_x; const w12_y = w1_y + w2_y; const offset12_x = w2_x / w12_x; const offset12_y = w2_y / w12_y; // Compute the final coordinates we'll use for sampling the texture const texPos0_x = texPos1_x - 1; const texPos0_y = texPos1_y - 1; const texPos3_x = texPos1_x + 2; const texPos3_y = texPos1_y + 2; const texPos12_x = texPos1_x + offset12_x; const texPos12_y = texPos1_y + offset12_y; let result = 0.0; result += this.sampleChannelBilinear(texPos0_x, texPos0_y, channel) * w0_x * w0_y; result += this.sampleChannelBilinear(texPos12_x, texPos0_y, channel) * w12_x * w0_y; result += this.sampleChannelBilinear(texPos3_x, texPos0_y, channel) * w3_x * w0_y; result += this.sampleChannelBilinear(texPos0_x, texPos12_y, channel) * w0_x * w12_y; result += this.sampleChannelBilinear(texPos12_x, texPos12_y, channel) * w12_x * w12_y; result += this.sampleChannelBilinear(texPos3_x, texPos12_y, channel) * w3_x * w12_y; result += this.sampleChannelBilinear(texPos0_x, texPos3_y, channel) * w0_x * w3_y; result += this.sampleChannelBilinear(texPos12_x, texPos3_y, channel) * w12_x * w3_y; result += this.sampleChannelBilinear(texPos3_x, texPos3_y, channel) * w3_x * w3_y; return result; } /** * * @param {number} u * @param {number} v * @param {number[]} result */ sampleBicubicUV(u, v, result) { const itemSize = this.itemSize; for (let i = 0; i < itemSize; i++) { result[i] = this.sampleChannelBicubicUV(u, v, i); } } /** * * @param {number} x * @param {number} y * @param {number[]|Float32Array|Float64Array} result * @param {number} result_offset */ sampleBicubic(x, y, result, result_offset) { const itemSize = this.itemSize; for (let i = 0; i < itemSize; i++) { result[i + result_offset] = this.sampleChannelBicubic(x, y, i); } } /** * * @param {number} u * @param {number} v * @param {number} channel * @returns {number} */ sampleChannelBicubicUV(u, v, channel) { const x = u * (this.width); const y = v * (this.height); return this.sampleChannelBicubic(x - 0.5, y - 0.5, channel); } /** * * Bicubic-filtered sampling, note values can be negative due to the nature of the cubic curve * @param {number} x * @param {number} y * @param {number} channel * @returns {number} */ sampleChannelBicubic(x, y, channel) { const itemSize = this.itemSize; const width = this.width; const height = this.height; const data = this.data; const rowSize = width * itemSize; const x_max = width - 1; const y_max = height - 1; const clamped_x = clamp(x, 0, x_max) const clamped_y = clamp(y, 0, y_max) // fractional texel position (from the nearest low texel) const x1 = clamped_x | 0; const y1 = clamped_y | 0; const xd = clamped_x - x1; const yd = clamped_y - y1; const x0 = max2(0, x1 - 1); const y0 = max2(0, y1 - 1); const x2 = min2(x_max, x1 + 1); const y2 = min2(y_max, y1 + 1); const x3 = min2(x_max, x2 + 1); const y3 = min2(y_max, y2 + 1); // compute row offsets const row0 = y0 * rowSize; const row1 = y1 * rowSize; const row2 = y2 * rowSize; const row3 = y3 * rowSize; const row0_address = row0 + channel; const row1_address = row1 + channel; const row2_address = row2 + channel; const row3_address = row3 + channel; const col0_offset = x0 * itemSize; const col1_offset = x1 * itemSize; const col2_offset = x2 * itemSize; const col3_offset = x3 * itemSize; // read samples const vi0 = data[row0_address + col0_offset]; const vi1 = data[row0_address + col1_offset]; const vi2 = data[row0_address + col2_offset]; const vi3 = data[row0_address + col3_offset]; const vj0 = data[row1_address + col0_offset]; const vj1 = data[row1_address + col1_offset]; const vj2 = data[row1_address + col2_offset]; const vj3 = data[row1_address + col3_offset]; const vk0 = data[row2_address + col0_offset]; const vk1 = data[row2_address + col1_offset]; const vk2 = data[row2_address + col2_offset]; const vk3 = data[row2_address + col3_offset]; const vl0 = data[row3_address + col0_offset]; const vl1 = data[row3_address + col1_offset]; const vl2 = data[row3_address + col2_offset]; const vl3 = data[row3_address + col3_offset]; // perform filtering in X (rows) const s0 = interpolate_bicubic(xd, vi0, vi1, vi2, vi3); const s1 = interpolate_bicubic(xd, vj0, vj1, vj2, vj3); const s2 = interpolate_bicubic(xd, vk0, vk1, vk2, vk3); const s3 = interpolate_bicubic(xd, vl0, vl1, vl2, vl3); // filter in Y (columns) return interpolate_bicubic(yd, s0, s1, s2, s3); } /** * * @param {number} u * @param {number} v * @param {number[]|Float32Array} result * @param {number} result_offset */ sampleBilinearUV(u, v, result, result_offset = 0) { const itemSize = this.itemSize; for (let i = 0; i < itemSize; i++) { result[i + result_offset] = this.sampleChannelBilinearUV(u, v, i); } } /** * * @param {number} x * @param {number} y * @param {number[]|Float32Array|Float64Array} result * @param {number} result_offset */ sampleBilinear(x, y, result, result_offset = 0) { const itemSize = this.itemSize; for (let i = 0; i < itemSize; i++) { //TODO this can be optimized greatly result[i + result_offset] = this.sampleChannelBilinear(x, y, i); } } /** * * @param {number} u * @param {number} v * @param {number} channel * @return {number} */ sampleChannelBilinearUV(u, v, channel) { const x = u * this.width - 0.5; const y = v * this.height - 0.5; return this.sampleChannelBilinear(x, y, channel); } /** * * @param {number} x * @param {number} y * @param {number} channel * @returns {number} */ sampleChannelBilinear(x, y, channel) { assert.isNumber(x, 'x'); assert.notNaN(x, 'x'); assert.isNumber(y, 'y'); assert.notNaN(y, 'y'); assert.isNonNegativeInteger(channel, 'channel'); const itemSize = this.itemSize; const width = this.width; const height = this.height; const rowSize = width * itemSize; //sample 4 points const x_max = width - 1; const y_max = height - 1; const clamped_x = clamp(x, 0, x_max); const clamped_y = clamp(y, 0, y_max); const x0 = clamped_x >>> 0; const y0 = clamped_y >>> 0; // const row0 = y0 * rowSize; const col0_offset = x0 * itemSize + channel; const i0 = row0 + col0_offset; // let x1, y1; if (clamped_x === x0) { x1 = x0; } else { x1 = x0 + 1; } if (clamped_y === y0) { y1 = y0; } else { y1 = y0 + 1; } const data = this.data; const q0 = data[i0]; if (x0 === x1 && y0 === y1) { // exactly sampled in the center of the pixel, no interpolation required return q0; } // const xd = clamped_x - x0; const yd = clamped_y - y0; const col1_offset = x1 * itemSize + channel; const i1 = row0 + col1_offset; const row1 = y1 * rowSize; const j0 = row1 + col0_offset; const j1 = row1 + col1_offset; const q1 = data[i1]; const p0 = data[j0]; const p1 = data[j1]; // perform Bi-Linear interpolation const s0 = lerp(q0, q1, xd); const s1 = lerp(p0, p1, xd); return lerp(s0, s1, yd); } /** * * @param {number} u * @param {number} v * @param {number[]|ArrayLike<number>} result */ sampleNearestUV(u, v, result) { const w = this.width; const h = this.height; const x = Math.round(u * w - 0.5); const y = Math.round(v * h - 0.5); this.read( clamp(x, 0, w - 1), clamp(y, 0, h - 1), result ); } /** * * @param {number} x * @param {number} y * @param {number} channel * @returns {number} */ readChannel(x, y, channel) { assert.isNumber(x, "x"); assert.isNumber(y, "y"); assert.isNumber(channel, "channel"); assert.isNonNegativeInteger(channel, 'channel'); assert.lessThan(channel, this.itemSize); const index = (y * this.width + x) * this.itemSize + channel; return this.data[index]; } /** * * @param {number} x * @param {number} y * @param {number[]} result */ read(x, y, result) { const width = this.width; const itemSize = this.itemSize; const i0 = (y * width + x) * itemSize; for (let i = 0; i < itemSize; i++) { result[i] = this.data[i0 + i]; } } /** * * @param {number} x * @param {number} y * @param {number[]|ArrayLike<number>} texel */ write(x, y, texel) { const width = this.width; const itemSize = this.itemSize; const i0 = (y * width + x) * itemSize; for (let i = 0; i < itemSize; i++) { this.data[i0 + i] = texel[i]; } this.version++; } /** * * @param {number} x * @param {number} y * @returns {number} */ point2index(x, y) { return x + y * this.width; } /** * * @param {number} index * @param {Vector2} result */ index2point(index, result) { const width = this.width; const x = index % width; const y = (index / width) | 0; result.set(x, y); } /** * Copy a patch from another sampler * @param {Sampler2D} source where to copy from * @param {Number} sourceX where to start reading from, X coordinate * @param {Number} sourceY where to start reading from, X coordinate * @param {Number} destinationX where to start writing to, X coordinate * @param {Number} destinationY where to start writing to, X coordinate * @param {Number} width size of the patch that is to be copied * @param {Number} height size of the patch that is to be copied */ copy( source, sourceX, sourceY, destinationX, destinationY, width, height ) { assert.isNumber(sourceX, 'sourceX'); assert.isNumber(sourceY, 'sourceY'); assert.isNumber(destinationX, 'destinationX'); assert.isNumber(destinationY, 'destinationY'); assert.isNumber(width, 'width'); assert.isNumber(height, 'height'); const _w = Math.min(width, source.width - sourceX, this.width - destinationX); const _h = Math.min(height, source.height - sourceY, this.height - destinationY); const dItemSize = this.itemSize; const sItemSize = source.itemSize; const _itemSize = Math.min(dItemSize, sItemSize); const dRowSize = dItemSize * this.width; const sRowSize = sItemSize * source.width; const sData = source.data; const dData = this.data; let x, y, i; for (y = 0; y < _h; y++) { const dA = (y + destinationY) * dRowSize; const sA = (y + sourceY) * sRowSize; for (x = 0; x < _w; x++) { const dOffset = dA + (x + destinationX) * dItemSize; const sOffset = sA + (x + sourceX) * sItemSize; for (i = 0; i < _itemSize; i++) { dData[dOffset + i] = sData[sOffset + i]; } } } this.version++; } /** * Fill data values with zeros for a given area * Specialized version of `fill` method, optimized for speed * @param {Number} x * @param {Number} y * @param {Number} width * @param {Number} height */ zeroFill(x, y, width, height) { assert.isNonNegativeInteger(width, 'width'); assert.isNonNegativeInteger(height, 'height'); const x0 = clamp(x, 0, this.width); const y0 = clamp(y, 0, this.height); const x1 = clamp(x + width, 0, this.width); const y1 = clamp(y + height, 0, this.height); // console.log(`#zerofill x:${x}, y:${y}, width:${width}, height:${height} \t -> \t x0:${x0}, y0:${y0}, x1:${x1}, y1:${y1}`); // DEBUG const data = this.data; const itemSize = this.itemSize; const rowSize = itemSize * this.width; const clearRowOffset0 = x0 * itemSize; const clearRowOffset1 = x1 * itemSize; let _y; for (_y = y0; _y < y1; _y++) { const a = _y * rowSize; data.fill(0, a + clearRowOffset0, a + clearRowOffset1); } this.version++; } /** * * @param {number} channel_index * @param {number} value */ channelFill(channel_index, value) { const itemSize = this.itemSize; const data = this.data; const length = data.length; for (let i = channel_index; i < length; i += itemSize) { data[i] = value; } this.version++; } /** * * @param {number} x * @param {number} y * @param {number} width * @param {number} height * @param {Array.<number>} value */ fill(x, y, width, height, value) { const _w = this.width; const _h = this.height; const x0 = clamp(x, 0, _w); const y0 = clamp(y, 0, _h); const x1 = clamp(x + width, 0, _w); const y1 = clamp(y + height, 0, _h); const data = this.data; const itemSize = this.itemSize; const rowSize = itemSize * _w; let _y, _x, i; for (_y = y0; _y < y1; _y++) { const a = _y * rowSize; for (_x = x0; _x < x1; _x++) { const offset = a + _x * itemSize; for (i = 0; i < itemSize; i++) { data[offset + i] = value[i]; } } } this.version++; } /** * Set channel value of a specific texel * @param {number} x * @param {number} y * @param {number} channel * @param {number} value */ writeChannel(x, y, channel, value) { assert.isNumber(x, "x"); assert.isNumber(y, "y"); assert.greaterThanOrEqual(x, 0); assert.greaterThanOrEqual(y, 0); assert.lessThan(x, this.width); assert.lessThan(y, this.height); assert.isNonNegativeInteger(channel, 'channel'); assert.lessThan(channel, this.itemSize); const pointIndex = y * this.width + x; const pointAddress = pointIndex * this.itemSize; const channelAddress = pointAddress + channel; this.data[channelAddress] = value; this.version++; } /** * Traverses area inside a circle * NOTE: Based on palm3d answer on stack overflow: https://stackoverflow.com/questions/1201200/fast-algorithm-for-drawing-filled-circles * @param {number} centerX * @param {number} centerY * @param {number} radius * @param {function(x:number,y:number, sampler:Sampler2D)} visitor */ traverseCircle(centerX, centerY, radius, visitor) { let x, y; //convert offsets to integers for safety const offsetX = centerX | 0; const offsetY = centerY | 0; const r2 = radius * radius; const radiusCeil = Math.ceil(radius); for (y = -radiusCeil; y <= radiusCeil; y++) { const y2 = y * y; for (x = -radiusCeil; x <= radiusCeil; x++) { if (x * x + y2 <= r2) { visitor(offsetX + x, offsetY + y, this); } } } } /** * * @param {number} x * @param {number} y * @param {boolean} [preserveData=true] */ resize(x, y, preserveData = true) { assert.isNonNegativeInteger(x, 'x'); assert.isNonNegativeInteger(y, 'y'); const _w = this.width; const _h = this.height; if (_w === x && _h === y) { // size didn't change return; } const itemSize = this.itemSize; const length = x * y * itemSize; const oldData = this.data; const Constructor = typedArrayConstructorByInstance(oldData); const newData = new Constructor(length); if (preserveData) { //copy old data if (x === _w) { // number of columns is preserved, we can just copy the old data across newData.set(oldData.subarray(0, Math.min(oldData.length, length))); } else { //we need to copy new data row-by-row const rowCount = min2(y, _h); const columnCount = min2(x, _w); for (let i = 0; i < rowCount; i++) { for (let j = 0; j < columnCount; j++) { const targetItemAddress = (i * x + j) * itemSize; const sourceItemAddress = (i * _w + j) * itemSize; for (let k = 0; k < itemSize; k++) { newData[targetItemAddress + k] = oldData[sourceItemAddress + k]; } } } } } this.width = x; this.height = y; this.data = newData; this.version++; } /** * Estimate memory requirement of the object * @return {number} */ computeByteSize() { let dataSize; const data = this.data; if (Array.isArray(data)) { // Assume IEEE float 64 dataSize = 8 * data.length; } else { dataSize = data.byteLength; } return dataSize + 280; } /** * * @param {Sampler2D} other * @return {boolean} */ equals(other) { if (this === other) { // special case return true; } if ( this.width !== other.width || this.height !== other.height || this.itemSize !== other.itemSize ) { return false; } return is_typed_array_equals(this.data, other.data); } /** * * @return {number} */ hash() { const item_size = this.itemSize; const width = this.width; const height = this.height; const data = this.data; let hash = (((width & 0xFFFF) << 16) | (height & 0xFFFF)) ^ item_size; const texel_count = width * height; const element_count = texel_count * item_size; // we want to hash all channels for each chosen texel if (isTypedArray(data)) { hash ^= array_buffer_hash(data.buffer, data.byteOffset, data.byteLength); } else { // floating point texture for (let i = 0; i < element_count; ++i) { const channel_value = data[i]; hash = ((hash << 5) - hash) + computeHashFloat(channel_value); } } return hash; } /** * @returns {Sampler2D} */ clone() { let data_clone; if (Array.isArray(this.data)) { data_clone = this.data.slice(); } else { // storage is a typed array const T = this.data.constructor; data_clone = new T(this.data); } return new Sampler2D(data_clone, this.itemSize, this.width, this.height); } toJSON() { const encoded = Base64.encode(this.data.buffer); return { height: this.height, width: this.width, itemSize: this.itemSize, type: compute_binary_data_type_from_typed_array(this.data), data: encoded } } fromJSON({ height, width, itemSize, type, data }) { const CTOR = compute_typed_array_constructor_from_data_type(type); if (typeof data === "string") { const decoded = Base64.decode(data); this.data = new CTOR(decoded); } else if (Array.isArray(data)) { // deprecated console.warn('Array JSON format is deprecated, please upgrade your data as soon as possible'); this.data = new CTOR(data); } else { throw new Error('Unsupported data format'); } this.height = height; this.width = width; this.itemSize = itemSize; } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static uint8clamped(itemSize, width, height) { const data = new Uint8ClampedArray(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static uint8(itemSize, width, height) { const data = new Uint8Array(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static uint16(itemSize, width, height) { const data = new Uint16Array(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static uint32(itemSize, width, height) { const data = new Uint32Array(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static int8(itemSize, width, height) { const data = new Int8Array(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static int16(itemSize, width, height) { const data = new Int16Array(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static int32(itemSize, width, height) { const data = new Int32Array(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static float32(itemSize, width, height) { const data = new Float32Array(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } /** * * @param {int} itemSize * @param {int} width * @param {int} height * @return {Sampler2D} */ static float64(itemSize, width, height) { const data = new Float64Array(width * height * itemSize); return new Sampler2D(data, itemSize, width, height); } } /** * @readonly * @type {boolean} */ Sampler2D.prototype.isSampler2D = true; /** * @readonly * @type {string} */ Sampler2D.typeName = "Sampler2D";