UNPKG

gsots3d

Version:

Getting S**t On The Screen in 3D. A library for doing 3D graphics in the browser.

1,516 lines (1,494 loc) 126 kB
// src/core/gl.ts import log from "loglevel"; var glContext; function getGl(selector = "canvas", aa = true) { if (glContext) { return glContext; } log.info(`\u{1F58C}\uFE0F Creating new WebGL2 context for: '${selector}'`); const canvasElement = document.querySelector(selector); if (!canvasElement) { log.error(`\u{1F4A5} FATAL! Unable to find element with selector: '${selector}'`); return void 0; } if (canvasElement && canvasElement.tagName !== "CANVAS") { log.error(`\u{1F4A5} FATAL! Element with selector: '${selector}' is not a canvas element`); return void 0; } const canvas = canvasElement; if (!canvas) { log.error(`\u{1F4A5} FATAL! Unable to find canvas element with selector: '${selector}'`); return void 0; } glContext = canvas.getContext("webgl2", { antialias: aa }) ?? void 0; if (!glContext) { log.error(`\u{1F4A5} Unable to create WebGL2 context, maybe it's not supported on this device`); return void 0; } log.info(`\u{1F4D0} Internal: ${canvas.width} x ${canvas.height}, display: ${canvas.clientWidth} x ${canvas.clientHeight}`); return glContext; } // src/core/context.ts import log8 from "loglevel"; import * as twgl9 from "twgl.js"; import { mat4 as mat48, vec3 as vec35 } from "gl-matrix"; // package.json var package_default = { name: "gsots3d", version: "0.0.6-alpha.1", description: "Getting S**t On The Screen in 3D. A library for doing 3D graphics in the browser.", author: "Ben Coleman", license: "MIT", homepage: "https://code.benco.io/gsots3d/docs", type: "module", publishConfig: { "@benc-uk:registry": "https://npm.pkg.github.com" }, repository: { type: "git", url: "https://github.com/benc-uk/gsots3d.git" }, exports: { ".": "./dist/index.js", "./parsers": "./dist/parsers/index.js" }, browser: { ".": "./dist-single/gsots3d.js" }, files: [ "dist/", "readme.md" ], keywords: [ "webgl", "graphics", "3d", "twgl", "typescript" ], scripts: { lint: "eslint src/ && prettier --check src/ && prettier --check shaders", "lint-fix": "eslint src/ --fix && prettier --write src/ && prettier --write shaders", check: "tsc", build: "tsc && tsup", "build:all": "npm run build && npm run build-single && npm run docs", watch: "tsc && npm run build && run-when-changed --watch 'src/**' --watch 'shaders/**' --exec 'npm run build'", "build-single": "tsc && tsup --config tsup.config-single.js", "watch-single": "tsc && npm run build-single && run-when-changed --watch 'src/**' --watch 'shaders/**' --exec 'npm run build-single'", clean: "rm -rf dist docs dist-single", docs: "typedoc --out docs --gitRevision main ./src/", examples: "vite --port 3000 --host 0.0.0.0 ./examples/", prepare: "npm run build" }, devDependencies: { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.16.0", "esbuild-plugin-glsl": "^1.2.2", eslint: "^9.16.0", globals: "^15.13.0", prettier: "^3.4.2", "prettier-plugin-glsl": "^0.2.0", "run-when-changed": "^2.1.0", tsup: "^8.3.5", typedoc: "^0.27.3", typescript: "^5.7.2", "typescript-eslint": "^8.17.0", vite: "^6.0.3" }, dependencies: { "cannon-es": "^0.20.0", "gl-matrix": "^3.4.3", loglevel: "^1.9.2", "twgl.js": "^5.5.4" } }; // src/engine/tuples.ts import { vec3 } from "gl-matrix"; import * as CANNON from "cannon-es"; function normalize(tuple) { const [x, y, z] = tuple; const len = Math.sqrt(x * x + y * y + z * z); return tuple.map((v) => v / len); } function scale(tuple, amount) { return tuple.map((v) => v * amount); } function scaleClamped(colour, amount) { scale(colour, amount); return colour.map((v) => Math.min(Math.max(v, 0), 1)); } function toVec3(tuple) { return vec3.fromValues(tuple[0], tuple[1], tuple[2]); } function distance(a, b) { return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2); } function add(a, b) { return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]; } function fromCannon(value) { if (value instanceof CANNON.Vec3) { return [value.x, value.y, value.z]; } return [value.x, value.y, value.z, value.w]; } function rgbColour255(r, g, b) { return [r / 255, g / 255, b / 255]; } function rgbColourHex(hexString) { const hex = hexString.replace("#", ""); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); return rgbColour255(r, g, b); } var Colours = { RED: [1, 0, 0], GREEN: [0, 1, 0], BLUE: [0, 0, 1], YELLOW: [1, 1, 0], CYAN: [0, 1, 1], MAGENTA: [1, 0, 1], BLACK: [0, 0, 0], WHITE: [1, 1, 1] }; var Tuples = { normalize, scale, scaleClamped, rgbColour255, rgbColourHex, toVec3, distance, fromCannon, add }; // src/core/cache.ts import log2 from "loglevel"; import { createTexture, createProgramInfo } from "twgl.js"; // src/core/files.ts async function fetchFile(filePath) { const resp = await fetch(filePath); if (!resp.ok) { throw new Error(`\u{1F4A5} File fetch failed: ${resp.statusText}`); } const text = await resp.text(); return text; } async function fetchShaders(vertPath, fragPath) { const vsResp = await fetch(vertPath); const fsResp = await fetch(fragPath); if (!vsResp.ok || !fsResp.ok) { throw new Error(`\u{1F4A5} Fetch failed - vertex: ${vsResp.statusText}, fragment: ${fsResp.statusText}`); } const vsText = await vsResp.text(); const fsText = await fsResp.text(); return { vertex: vsText, fragment: fsText }; } // src/core/cache.ts var PROG_DEFAULT = "phong"; var PROG_BILLBOARD = "billboard"; var ModelCache = class _ModelCache { constructor() { this.cache = /* @__PURE__ */ new Map(); } /** * Return the singleton instance of the model cache */ static get instance() { if (!_ModelCache._instance) { _ModelCache._instance = new _ModelCache(); } return _ModelCache._instance; } /** * Return a model from the cache by name * @param name Name of model without extension * @param warn If true, log a warning if model not found */ get(name, warn = true) { if (!this.cache.has(name) && warn) { log2.warn(`\u26A0\uFE0F Model '${name}' not found, please load it first`); return void 0; } return this.cache.get(name); } /** * Add a model to the cache, using the model name as key */ add(model) { log2.debug(`\u{1F9F0} Adding model '${model.name}' to cache`); this.cache.set(model.name, model); } }; var _TextureCache = class _TextureCache { constructor() { this.cache = /* @__PURE__ */ new Map(); this.gl = {}; this.defaultWhite = {}; this.defaultRand = {}; } // Create a new texture cache static init(gl, randSize = 512) { this._instance = new _TextureCache(); this._instance.gl = gl; const white1pixel = createTexture(gl, { min: gl.NEAREST, mag: gl.NEAREST, src: [255, 255, 255, 255] }); const randArray = new Uint8Array(randSize * randSize * 4); for (let i = 0; i < randSize * randSize * 4; i++) { randArray[i] = Math.floor(Math.random() * 255); } const randomRGB = createTexture(gl, { min: gl.NEAREST, mag: gl.NEAREST, src: randArray, width: randSize, height: randSize, wrap: gl.REPEAT }); this._instance.defaultWhite = white1pixel; this._instance.defaultRand = randomRGB; _TextureCache.initialized = true; } /** * Return the singleton instance of the texture cache */ static get instance() { if (!_TextureCache.initialized) { throw new Error("\u{1F4A5} TextureCache not initialized, call TextureCache.init() first"); } return this._instance; } /** * Return a texture from the cache by name * @param key Key of texture, this is usually the URL or filename path */ get(key) { if (!this.cache.has(key)) { log2.warn(`\u{1F4A5} Texture ${key} not found in cache`); return void 0; } log2.trace(`\u{1F44D} Returning texture '${key}' from cache, nice!`); return this.cache.get(key); } /** * Add a texture to the cache * @param key Key of texture, this is usually the URL or filename path * @param texture WebGL texture */ add(key, texture) { if (this.cache.has(key)) { log2.warn(`\u{1F914} Texture '${key}' already in cache, not adding again`); return; } log2.debug(`\u{1F9F0} Adding texture '${key}' to cache`); this.cache.set(key, texture); } /** * Create or return a texture from the cache by name * @param src URL or filename path of texture image, or ArrayBufferView holding texture * @param filter Enable texture filtering and mipmaps (default true) * @param flipY Flip the texture vertically (default true) * @param textureKey Unique key, only used for ArrayBuffer textures * @param extraOptions Extra options to pass to twgl.createTexture, see https://twgljs.org/docs/module-twgl.html#.TextureOptions */ getCreate(src, filter = true, flipY = false, textureKey = "", extraOptions = {}) { let key = ""; if (typeof src === "string") { key = src; } else { if (textureKey === "") { throw new Error("\u{1F4A5} ArrayBuffer textures need a unique key"); } key = textureKey; } if (this.cache.has(key)) { log2.trace(`\u{1F44D} Returning texture '${key}' from cache, nice!`, flipY); return this.get(key); } const texture = createTexture( this.gl, { ...extraOptions, min: filter ? this.gl.LINEAR_MIPMAP_LINEAR : this.gl.NEAREST, mag: filter ? this.gl.LINEAR : this.gl.NEAREST, src, flipY: flipY ? 1 : 0 }, (err) => { if (err) { log2.error("\u{1F4A5} Error loading texture", err); } } ); this.add(key, texture); return texture; } /** * Return the default white 1x1 texture */ static get defaultWhite() { return this.instance.defaultWhite; } /** * Return the default random RGB texture */ static get defaultRand() { return this.instance.defaultRand; } /** * Return the number of textures in the cache */ static get size() { return this.instance.cache.size; } /** * Clear the texture cache */ static clear() { this.instance.cache.clear(); } }; _TextureCache.initialized = false; var TextureCache = _TextureCache; var _ProgramCache = class _ProgramCache { /** * Create a new program cache, can't be used until init() is called */ constructor() { this.cache = /* @__PURE__ */ new Map(); this._default = {}; } /** * Initialise the program cache with a default program. * This MUST be called before using the cache * @param defaultProg The default program that can be used by most things */ static init(defaultProg) { if (_ProgramCache._instance) { log2.warn("\u{1F914} Program cache already initialised, not doing it again"); return; } _ProgramCache._instance = new _ProgramCache(); _ProgramCache._instance._default = defaultProg; _ProgramCache.initialized = true; } /** * Compile a custom shader and add it to the cache * @param name Assign a name to the shader * @param vert URL path to vertex shader * @param frag URL path to fragment shader */ async compileShader(name, vert, frag) { const gl = getGl(); if (!gl) { throw new Error("\u{1F4A5} WebGL context not found"); } const { vertex: vsText, fragment: fsText } = await fetchShaders(vert, frag); const progInfo = createProgramInfo(gl, [vsText, fsText]); console.log("\u{1F9F0} Adding custom shader to cache", name, progInfo); this.add(name, progInfo); } setDefaultProgram(name) { this._default = this.cache.get(name) || this._default; } /** * Return the singleton instance of the program cache */ static get instance() { if (!_ProgramCache.initialized) { throw new Error("\u{1F4A5} Program cache not initialised, call init() first"); } return _ProgramCache._instance; } /** * Return a program from the cache by name * @param name Name of program */ get(name) { const prog = this.cache.get(name); if (!prog) { log2.warn(`\u26A0\uFE0F Program '${name}' not found, returning default`); return this._default; } return prog; } add(name, program) { log2.debug(`\u{1F9F0} Adding program '${name}' to cache`); this.cache.set(name, program); } get default() { return this._default; } }; _ProgramCache.initialized = false; _ProgramCache.PROG_PHONG = "phong"; _ProgramCache.PROG_BILLBOARD = "billboard"; _ProgramCache.PROG_SHADOWMAP = "shadowmap"; var ProgramCache = _ProgramCache; // src/engine/lights.ts import { mat4 as mat42 } from "gl-matrix"; import * as twgl from "twgl.js"; // src/engine/camera.ts import { mat4, vec3 as vec32 } from "gl-matrix"; import log3 from "loglevel"; var CameraType = /* @__PURE__ */ ((CameraType2) => { CameraType2[CameraType2["PERSPECTIVE"] = 0] = "PERSPECTIVE"; CameraType2[CameraType2["ORTHOGRAPHIC"] = 1] = "ORTHOGRAPHIC"; return CameraType2; })(CameraType || {}); var Camera = class { /** * Create a new default camera */ constructor(type = 0 /* PERSPECTIVE */, aspectRatio = 1) { // Used to clamp first person up/down angle this.maxAngleUp = Math.PI / 2 - 0.01; this.maxAngleDown = -Math.PI / 2 + 0.01; this.touches = []; this.type = type; this.active = true; this.position = [0, 0, 30]; this.lookAt = [0, 0, 0]; this.up = [0, 1, 0]; this.near = 0.1; this.far = 100; this.fov = 45; this.aspectRatio = aspectRatio; this.orthoZoom = 20; this.usedForEnvMap = false; this.usedForShadowMap = false; this.fpMode = false; this.fpAngleY = 0; this.fpAngleX = 0; this.fpTurnSpeed = 1e-3; this.fpMoveSpeed = 1; this.fpHandlersAdded = false; this.fpFly = false; this.keysDown = /* @__PURE__ */ new Set(); } /** * Get the current view matrix for the camera */ get matrix() { if (!this.fpMode) { const camView2 = mat4.targetTo(mat4.create(), this.position, this.lookAt, this.up); return camView2; } const camView = mat4.targetTo(mat4.create(), [0, 0, 0], [0, 0, -1], this.up); mat4.translate(camView, camView, this.position); mat4.rotateY(camView, camView, this.fpAngleY); mat4.rotateX(camView, camView, this.fpAngleX); return camView; } /** * Get the projection matrix for this camera * @param aspectRatio Aspect ratio of the canvas */ get projectionMatrix() { if (this.type === 1 /* ORTHOGRAPHIC */) { const camProj = mat4.ortho( mat4.create(), -this.aspectRatio * this.orthoZoom, this.aspectRatio * this.orthoZoom, -this.orthoZoom, this.orthoZoom, this.near, this.far ); return camProj; } else { const camProj = mat4.perspective(mat4.create(), this.fov * (Math.PI / 180), this.aspectRatio, this.near, this.far); return camProj; } } /** * Get the center of the camera view frustum * @param scale how much to scale the frustum towards the far plane, default: 1 * @returns Point in world space */ getFrustumCenter(scaleFar = 1) { const frustum = this.frustumCornersWorld(scaleFar); return [frustum.center[0], frustum.center[1], frustum.center[2]]; } /** * Get the corners of the view frustum for this camera in world space * @param scaleFar Scale the far plane to bring the frustum closer, default: 1 */ frustumCornersWorld(scaleFar = 1) { const far = this.far * scaleFar; const nearHeight = Math.tan(this.fov * (Math.PI / 180) / 2) * this.near; const nearWidth = nearHeight * this.aspectRatio; const farHeight = Math.tan(this.fov * (Math.PI / 180) / 2) * far; const farWidth = farHeight * this.aspectRatio; const nearTopLeft = vec32.fromValues(nearWidth, nearHeight, -this.near); const nearTopRight = vec32.fromValues(-nearWidth, nearHeight, -this.near); const nearBottomLeft = vec32.fromValues(nearWidth, -nearHeight, -this.near); const nearBottomRight = vec32.fromValues(-nearWidth, -nearHeight, -this.near); const farTopLeft = vec32.fromValues(farWidth, farHeight, -far); const farTopRight = vec32.fromValues(-farWidth, farHeight, -far); const farBottomLeft = vec32.fromValues(farWidth, -farHeight, -far); const farBottomRight = vec32.fromValues(-farWidth, -farHeight, -far); const nearTopLeftWorld = vec32.transformMat4(vec32.create(), nearTopLeft, this.matrix); const nearTopRightWorld = vec32.transformMat4(vec32.create(), nearTopRight, this.matrix); const nearBottomLeftWorld = vec32.transformMat4(vec32.create(), nearBottomLeft, this.matrix); const nearBottomRightWorld = vec32.transformMat4(vec32.create(), nearBottomRight, this.matrix); const farTopLeftWorld = vec32.transformMat4(vec32.create(), farTopLeft, this.matrix); const farTopRightWorld = vec32.transformMat4(vec32.create(), farTopRight, this.matrix); const farBottomLeftWorld = vec32.transformMat4(vec32.create(), farBottomLeft, this.matrix); const farBottomRightWorld = vec32.transformMat4(vec32.create(), farBottomRight, this.matrix); const center = vec32.create(); vec32.add(center, nearTopLeftWorld, nearTopRightWorld); vec32.add(center, center, nearBottomLeftWorld); vec32.add(center, center, nearBottomRightWorld); vec32.add(center, center, farTopLeftWorld); vec32.add(center, center, farTopRightWorld); vec32.add(center, center, farBottomLeftWorld); vec32.add(center, center, farBottomRightWorld); vec32.scale(center, center, 1 / 8); return { nearTopLeftWorld, nearTopRightWorld, nearBottomLeftWorld, nearBottomRightWorld, farTopLeftWorld, farTopRightWorld, farBottomLeftWorld, farBottomRightWorld, center }; } /** * Get the camera position as a string for debugging */ toString() { const pos = this.position.map((p) => p.toFixed(2)); return `position: [${pos}]`; } /** * Switches the camera to first person mode, where the camera is controlled by * the mouse and keyboard. The mouse controls look direction and the keyboard * controls movement. * @param angleY Starting look up/down angle in radians, default 0 * @param angleX Starting look left/right angle in radians, default 0 * @param turnSpeed Speed of looking in radians, default 0.001 * @param moveSpeed Speed of moving in units, default 1.0 */ enableFPControls(angleY = 0, angleX = 0, turnSpeed = 1e-3, moveSpeed = 1, fly = false) { this.fpMode = true; this.fpAngleY = angleY; this.fpAngleX = angleX; this.fpTurnSpeed = turnSpeed; this.fpMoveSpeed = moveSpeed; this.fpFly = fly; if (this.fpHandlersAdded) return; const gl = getGl(); gl?.canvas.addEventListener("click", async () => { if (!this.fpMode || !this.active) return; if (document.pointerLockElement) { document.exitPointerLock(); } else { await (gl?.canvas).requestPointerLock(); } }); window.addEventListener("mousemove", (e) => { if (!document.pointerLockElement) { return; } if (!this.fpMode || !this.active) return; this.fpAngleY += e.movementX * -this.fpTurnSpeed; this.fpAngleX += e.movementY * -this.fpTurnSpeed; if (this.fpAngleX > this.maxAngleUp) this.fpAngleX = this.maxAngleUp; if (this.fpAngleX < this.maxAngleDown) this.fpAngleX = this.maxAngleDown; const dZ = -Math.cos(this.fpAngleY) * 1; const dX = -Math.sin(this.fpAngleY) * 1; const dY = Math.sin(this.fpAngleX) * 1; this.lookAt = [this.position[0] + dX, this.position[1] + dY, this.position[2] + dZ]; }); window.addEventListener("keydown", (e) => { if (!this.fpMode || !this.active) return; this.keysDown.add(e.key); }); window.addEventListener("keyup", (e) => { if (!this.fpMode || !this.active) return; this.keysDown.delete(e.key); }); window.addEventListener("touchstart", (e) => { if (!this.fpMode || !this.active) return; if (e.touches[0].clientX > window.innerWidth / 2) { this.touches[0] = e.touches[0]; } if (e.touches[0].clientX < window.innerWidth / 2) { if (e.touches[0].clientY < window.innerHeight / 2) { this.keysDown.add("w"); } if (e.touches[0].clientY > window.innerHeight / 2) { this.keysDown.add("s"); } } }); window.addEventListener("touchend", () => { if (!this.fpMode || !this.active) return; this.touches = []; this.keysDown.clear(); }); window.addEventListener("touchmove", (e) => { if (!this.fpMode || !this.active) return; if (this.touches.length === 0) return; const touch = e.touches[0]; const dx = touch.clientX - this.touches[0].clientX; const dy = touch.clientY - this.touches[0].clientY; this.fpAngleY += dx * -this.fpTurnSpeed * touch.force * 4; this.fpAngleX += dy * -this.fpTurnSpeed * touch.force * 4; if (this.fpAngleX > this.maxAngleUp) this.fpAngleX = this.maxAngleUp; if (this.fpAngleX < this.maxAngleDown) this.fpAngleX = this.maxAngleDown; this.touches[0] = touch; }); this.fpHandlersAdded = true; log3.info("\u{1F3A5} Camera: first person mode & controls enabled"); } /** * Disable FP mode */ disableFPControls() { this.fpMode = false; document.exitPointerLock(); log3.debug("\u{1F3A5} Camera: FPS mode disabled"); } /** * Get FP mode state */ get fpModeEnabled() { return this.fpMode; } /** * Called every frame to update the camera, currently only used for movement in FP mode */ update() { if (!this.fpMode || !this.active) return; if (this.keysDown.size === 0) return; const dZ = -Math.cos(this.fpAngleY) * this.fpMoveSpeed; const dY = Math.sin(this.fpAngleX) * this.fpMoveSpeed; const dX = -Math.sin(this.fpAngleY) * this.fpMoveSpeed; for (const key of this.keysDown.values()) { switch (key) { case "ArrowUp": case "w": this.position[0] += dX; if (this.fpFly) this.position[1] += dY; this.position[2] += dZ; this.lookAt[0] += dX; this.lookAt[2] += dZ; break; case "ArrowDown": case "s": this.position[0] -= dX; if (this.fpFly) this.position[1] -= dY; this.position[2] -= dZ; this.lookAt[0] -= dX; this.lookAt[2] -= dZ; break; case "ArrowLeft": case "a": this.position[0] += dZ; this.position[2] -= dX; this.lookAt[0] += dZ; this.lookAt[2] -= dX; break; case "ArrowRight": case "d": this.position[0] -= dZ; this.position[2] += dX; this.lookAt[0] -= dZ; this.lookAt[2] += dX; break; case "]": this.position[1] += this.fpMoveSpeed * 0.75; this.lookAt[1] += this.fpMoveSpeed * 0.75; break; case "[": this.position[1] -= this.fpMoveSpeed * 0.75; this.lookAt[1] -= this.fpMoveSpeed * 0.75; break; } } } }; // shaders/shadowmap/glsl.frag var glsl_default = "#version 300 es\nprecision highp float;void main(){}"; // shaders/shadowmap/glsl.vert var glsl_default2 = "#version 300 es\nprecision highp float;in vec4 position;uniform mat4 u_worldViewProjection;void main(){gl_Position=u_worldViewProjection*position;}"; // src/engine/lights.ts var LightDirectional = class { /** Create a default directional light, pointing downward */ constructor() { this._direction = [0, -1, 0]; this.colour = Colours.WHITE; this.ambient = Colours.BLACK; this.enabled = true; const gl = getGl(); if (!gl) { throw new Error("\u{1F4A5} LightDirectional: Cannot create shadow map shader, no GL context"); } this._shadowMapProgram = twgl.createProgramInfo(gl, [glsl_default2, glsl_default], ["shadowProgram"]); } /** * Set the direction of the light ensuring it is normalized * @param direction - Direction vector */ set direction(direction) { this._direction = Tuples.normalize(direction); } /** * Get the direction of the light */ get direction() { return this._direction; } /** * Convenience method allows setting the direction as a point relative to the world origin * Values are always converted to a normalized unit direction vector * @param x - X position * @param y - Y position * @param z - Z position */ setAsPosition(x, y, z) { this._direction = Tuples.normalize([0 - x, 0 - y, 0 - z]); } /** * Return the base set of uniforms for this light */ get uniforms() { return { direction: this.direction, colour: this.enabled ? this.colour : [0, 0, 0], ambient: this.ambient ? this.ambient : [0, 0, 0] }; } /** * Enable shadows for this light, this will create a shadow map texture and framebuffer * There is no way to disabled shadows once enabled * @param options A set of ShadowOptions to configure how shadows are calculated */ enableShadows(options) { this._shadowOptions = options ?? {}; if (!this._shadowOptions.mapSize) { this._shadowOptions.mapSize = 512; } if (!this._shadowOptions.zoom) { this._shadowOptions.zoom = 120; } if (!this._shadowOptions.distance) { this._shadowOptions.distance = 1e3; } if (!this._shadowOptions.polygonOffset) { this._shadowOptions.polygonOffset = 0; } const gl = getGl(); if (!gl) { throw new Error("\u{1F4A5} LightDirectional: Cannot create shadow map, no GL context"); } this._shadowMapTex = twgl.createTexture(gl, { width: this._shadowOptions.mapSize, height: this._shadowOptions.mapSize, internalFormat: gl.DEPTH_COMPONENT32F, // Makes this a depth texture compareMode: gl.COMPARE_REF_TO_TEXTURE, // Becomes a shadow map, e.g. sampler2DShadow minMag: gl.LINEAR // Can be linear sampled only if compare mode is set }); this._shadowMapFB = twgl.createFramebufferInfo( gl, [{ attachment: this._shadowMapTex, attachmentPoint: gl.DEPTH_ATTACHMENT }], this._shadowOptions.mapSize, this._shadowOptions.mapSize ); } /** * Get a virtual camera that can be used to render a shadow map for this light * @param viewCam - The main camera used to view the scene, needed to get a good shadow view */ getShadowCamera(viewCam) { if (!this._shadowOptions) { return void 0; } const corners = viewCam.frustumCornersWorld(this._shadowOptions.zoom / viewCam.far); const viewFrustumCenter = corners.center; const cam = new Camera(1 /* ORTHOGRAPHIC */, 1); cam.usedForShadowMap = true; cam.position = [ viewFrustumCenter[0] + -this.direction[0] * this._shadowOptions.distance, viewFrustumCenter[1] + -this.direction[1] * this._shadowOptions.distance, viewFrustumCenter[2] + -this.direction[2] * this._shadowOptions.distance ]; cam.lookAt = viewFrustumCenter; cam.far = this._shadowOptions.distance * 2; cam.orthoZoom = this._shadowOptions.zoom; return cam; } /** * Get the forward view matrix for the virtual camera used to render the shadow map. * Returns undefined if shadows are not enabled * @param viewCam - The main camera used to view the scene, needed to get a good shadow view */ getShadowMatrix(viewCam) { if (!this._shadowOptions) { return void 0; } const shadowCam = this.getShadowCamera(viewCam); if (!shadowCam) { return void 0; } const shadowMat = mat42.multiply( mat42.create(), shadowCam.projectionMatrix, mat42.invert(mat42.create(), shadowCam.matrix) ); return shadowMat; } /** * Are shadows enabled for this light? */ get shadowsEnabled() { return this._shadowOptions !== void 0; } /** * Get the shadow map program, will be undefined if shadows are not enabled */ get shadowMapProgram() { return this._shadowMapProgram; } /** * Get the shadow map framebuffer, will be undefined if shadows are not enabled */ get shadowMapFrameBufffer() { return this._shadowMapFB; } /** * Get the shadow map texture, will be undefined if shadows are not enabled */ get shadowMapTexture() { return this._shadowMapTex; } /** * Get the shadow map options, will be undefined if shadows are not enabled */ get shadowMapOptions() { return this._shadowOptions; } }; var LightPoint = class { /** * Create a default point light, positioned at the world origin * @param position - Position of the light in world space * @param colour - Colour of the light * @param constant - Attenuation constant drop off rate, default 0.5 * @param linear - Attenuation linear drop off rate, default 0.018 * @param quad - Attenuation quadratic drop off rate, default 0.0003 */ constructor(position, colour, constant = 0.5, linear = 0.018, quad = 3e-4) { this.position = position; this.colour = colour; this.constant = constant; this.linear = linear; this.quad = quad; this.ambient = Colours.BLACK; this.enabled = true; this.metadata = {}; } /** * Return the base set of uniforms for this light */ get uniforms() { return { enabled: this.enabled, quad: this.quad, position: this.position, colour: this.colour, ambient: this.ambient, constant: this.constant, linear: this.linear }; } }; // src/engine/envmap.ts import * as twgl2 from "twgl.js"; import { mat4 as mat43 } from "gl-matrix"; import log4 from "loglevel"; // shaders/envmap/glsl.frag var glsl_default3 = "#version 300 es\nprecision highp float;in vec3 v_texCoord;uniform samplerCube u_envMapTex;out vec4 outColour;void main(){outColour=texture(u_envMapTex,v_texCoord);}"; // shaders/envmap/glsl.vert var glsl_default4 = "#version 300 es\nprecision highp float;in vec4 position;uniform mat4 u_worldViewProjection;out vec3 v_texCoord;void main(){v_texCoord=position.xyz;gl_Position=u_worldViewProjection*position;}"; // src/core/stats.ts var Stats = { drawCallsPerFrame: 0, _drawCallsPerFramePrev: 0, instances: 0, triangles: 0, prevTime: 0, deltaTime: 0, totalTime: 0, frameCount: 0, fpsBucket: [], resetPerFrame() { Stats._drawCallsPerFramePrev = Stats.drawCallsPerFrame; Stats.drawCallsPerFrame = 0; }, updateTime(now) { Stats.deltaTime = now * 1e-3 - Stats.prevTime; Stats.prevTime = now * 1e-3; Stats.totalTime += Stats.deltaTime; Stats.fpsBucket.push(Stats.deltaTime); if (Stats.fpsBucket.length > 10) { Stats.fpsBucket.shift(); } }, get FPS() { const sum = Stats.fpsBucket.reduce((a, b) => a + b, 0); return Math.round(1 / (sum / Stats.fpsBucket.length)); }, get totalTimeRound() { return Math.round(Stats.totalTime); }, get drawCalls() { return Stats._drawCallsPerFramePrev; } }; // src/engine/envmap.ts var EnvironmentMap = class { /** * Create a new environment map with 6 textures for each side * @param gl GL context * @param textureURLs Array of 6 texture URLs, in order: +x, -x, +y, -y, +z, -z */ constructor(gl, textureURLs) { this.gl = gl; this.programInfo = twgl2.createProgramInfo(gl, [glsl_default4, glsl_default3]); this.cube = twgl2.primitives.createCubeBufferInfo(gl, 1); this.renderAsBackground = true; log4.info(`\u{1F3D4}\uFE0F EnvironmentMap created!`); if (textureURLs.length !== 6) { throw new Error("\u{1F4A5} Cubemap requires 6 textures"); } this._texture = twgl2.createTexture(gl, { target: gl.TEXTURE_CUBE_MAP, src: textureURLs, min: gl.LINEAR_MIPMAP_LINEAR, mag: gl.LINEAR, cubeFaceOrder: [ gl.TEXTURE_CUBE_MAP_POSITIVE_X, gl.TEXTURE_CUBE_MAP_NEGATIVE_X, gl.TEXTURE_CUBE_MAP_POSITIVE_Y, gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, gl.TEXTURE_CUBE_MAP_POSITIVE_Z, gl.TEXTURE_CUBE_MAP_NEGATIVE_Z ], flipY: 0 }); } /** * Render this envmap as a cube around the given camera & matrices * This is used for rendering the envmap as a background and skybox around the scene * @param viewMatrix View matrix * @param projMatrix Projection matrix * @param camera Camera */ render(viewMatrix, projMatrix, camera) { if (!this.renderAsBackground) return; this.gl.useProgram(this.programInfo.program); this.gl.disable(this.gl.DEPTH_TEST); const uniforms = { u_envMapTex: this._texture, u_worldViewProjection: mat43.create() }; const world = mat43.create(); mat43.translate(world, world, camera.position); mat43.scale(world, world, [camera.far, camera.far, camera.far]); const worldView = mat43.multiply(mat43.create(), viewMatrix, world); mat43.multiply(uniforms.u_worldViewProjection, projMatrix, worldView); twgl2.setBuffersAndAttributes(this.gl, this.programInfo, this.cube); twgl2.setUniforms(this.programInfo, uniforms); twgl2.drawBufferInfo(this.gl, this.cube); Stats.drawCallsPerFrame++; this.gl.enable(this.gl.DEPTH_TEST); } get texture() { return this._texture; } }; var DynamicEnvironmentMap = class { /** * Create a new dynamic environment map * @param gl GL context * @param size Size of each face of the cube map * @param position Position of the center of the cube map, reflections will be rendered from here */ constructor(gl, size, position, far) { this.facings = []; this._texture = twgl2.createTexture(gl, { target: gl.TEXTURE_CUBE_MAP, width: size, height: size, minMag: gl.LINEAR, cubeFaceOrder: [ gl.TEXTURE_CUBE_MAP_POSITIVE_X, gl.TEXTURE_CUBE_MAP_NEGATIVE_X, gl.TEXTURE_CUBE_MAP_POSITIVE_Y, gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, gl.TEXTURE_CUBE_MAP_POSITIVE_Z, gl.TEXTURE_CUBE_MAP_NEGATIVE_Z ] }); this.facings = [ { face: gl.TEXTURE_CUBE_MAP_POSITIVE_X, direction: [1, 0, 0], buffer: twgl2.createFramebufferInfo( gl, [{ attachment: this._texture, target: gl.TEXTURE_CUBE_MAP_POSITIVE_X }, { format: gl.DEPTH_COMPONENT16 }], size, size ) }, { face: gl.TEXTURE_CUBE_MAP_NEGATIVE_X, direction: [-1, 0, 0], buffer: twgl2.createFramebufferInfo( gl, [{ attachment: this._texture, target: gl.TEXTURE_CUBE_MAP_NEGATIVE_X }, { format: gl.DEPTH_COMPONENT16 }], size, size ) }, { face: gl.TEXTURE_CUBE_MAP_POSITIVE_Y, direction: [0, 1, 0], buffer: twgl2.createFramebufferInfo( gl, [{ attachment: this._texture, target: gl.TEXTURE_CUBE_MAP_POSITIVE_Y }, { format: gl.DEPTH_COMPONENT16 }], size, size ) }, { face: gl.TEXTURE_CUBE_MAP_NEGATIVE_Y, direction: [0, -1, 0], buffer: twgl2.createFramebufferInfo( gl, [{ attachment: this._texture, target: gl.TEXTURE_CUBE_MAP_NEGATIVE_Y }, { format: gl.DEPTH_COMPONENT16 }], size, size ) }, { face: gl.TEXTURE_CUBE_MAP_POSITIVE_Z, direction: [0, 0, 1], buffer: twgl2.createFramebufferInfo( gl, [{ attachment: this._texture, target: gl.TEXTURE_CUBE_MAP_POSITIVE_Z }, { format: gl.DEPTH_COMPONENT16 }], size, size ) }, { face: gl.TEXTURE_CUBE_MAP_NEGATIVE_Z, direction: [0, 0, -1], buffer: twgl2.createFramebufferInfo( gl, [{ attachment: this._texture, target: gl.TEXTURE_CUBE_MAP_NEGATIVE_Z }, { format: gl.DEPTH_COMPONENT16 }], size, size ) } ]; this.camera = new Camera(0 /* PERSPECTIVE */); this.camera.position = position; this.camera.fov = 90; this.camera.usedForEnvMap = true; this.camera.far = far; } /** Get the texture of the environment cubemap */ get texture() { return this._texture; } /** * This is used to position the camera for creating the reflection map * @param position Position of the center of the cube map */ set position(pos) { this.camera.position = pos; } /** * Update the environment map, by rendering the scene from the given position into the cubemap texture * @param ctx GSOTS Context */ update(gl, ctx) { for (const facing of this.facings) { this.camera.lookAt = [ this.camera.position[0] + facing.direction[0], this.camera.position[1] + facing.direction[1], this.camera.position[2] + facing.direction[2] ]; this.camera.up = [0, -1, 0]; if (facing.face === gl.TEXTURE_CUBE_MAP_NEGATIVE_Y) { this.camera.up = [0, 0, -1]; } if (facing.face === gl.TEXTURE_CUBE_MAP_POSITIVE_Y) { this.camera.up = [0, 0, 1]; } twgl2.bindFramebufferInfo(gl, facing.buffer); ctx.renderWithCamera(this.camera); } } }; // src/renderable/instance.ts import { mat4 as mat45 } from "gl-matrix"; // src/engine/node.ts import { mat4 as mat44, quat } from "gl-matrix"; import log5 from "loglevel"; var EVENT_POSITION = "position"; var Node = class { /** Create a default node, at origin with scale of [1,1,1] and no rotation */ constructor() { this._children = []; this.id = uniqueId(); this.metadata = {}; this.eventHandlers = /* @__PURE__ */ new Map(); this.eventHandlers.set(EVENT_POSITION, []); this.position = [0, 0, 0]; this.scale = [1, 1, 1]; this.quaternion = quat.create(); this._enabled = true; this._receiveShadow = true; this._castShadow = true; this._physicsBody = void 0; log5.debug(`\u{1F4CD} Node created with id ${this.id}`); } /** Rotate this instance around the X, Y and Z axis in radians */ rotate(ax, ay, az) { quat.rotateX(this.quaternion, this.quaternion, ax); quat.rotateY(this.quaternion, this.quaternion, ay); quat.rotateZ(this.quaternion, this.quaternion, az); } /** Rotate this instance around the X axis*/ rotateX(angle) { quat.rotateX(this.quaternion, this.quaternion, angle); } /** Rotate this instance around the Y axis*/ rotateY(angle) { quat.rotateY(this.quaternion, this.quaternion, angle); } /** Rotate this instance around the Z axis, in radians*/ rotateZ(angle) { quat.rotateZ(this.quaternion, this.quaternion, angle); } /** Rotate this instance around the X axis by a given angle in degrees */ rotateZDeg(angle) { this.rotateZ(angle * Math.PI / 180); } /** Rotate this instance around the Y axis by a given angle in degrees */ rotateYDeg(angle) { this.rotateY(angle * Math.PI / 180); } /** Rotate this instance around the Z axis by a given angle in degrees */ rotateXDeg(angle) { this.rotateX(angle * Math.PI / 180); } /** Set the rotation quaternion directly, normally users should use the rotate methods. * This method is for advanced uses, like integration with an external physics system */ setQuaternion(quatArray) { this.quaternion = quat.fromValues(quatArray[0], quatArray[1], quatArray[2], quatArray[3]); } /** Get the rotation quaternion as a XYZW 4-tuple */ getQuaternion() { return [this.quaternion[0], this.quaternion[1], this.quaternion[2], this.quaternion[3]]; } /** * Return the world or model matrix for this node, this is the matrix that places this node in the world. * This will be in relation to the parent node, if there is one. */ get modelMatrix() { const modelMatrix = mat44.fromRotationTranslationScale(mat44.create(), this.quaternion, this.position, this.scale); if (!this.parent) { return modelMatrix; } mat44.multiply(modelMatrix, this.parent.modelMatrix ?? mat44.create(), modelMatrix); return modelMatrix; } /** Convenience method to make another Node a child of this one */ addChild(node) { node._parent = this; this._children.push(node); } /** Convenience method to remove a child Node */ removeChild(node) { node._parent = void 0; this._children = this._children.filter((child) => child.id !== node.id); } /** Convenience method to remove all child Nodes */ removeAllChildren() { this._children.forEach((child) => { child._parent = void 0; }); this._children = []; } /** Sets the parent this Node, to the provided Node */ set parent(node) { if (this._parent) { this._parent.removeChild(this); } if (node) { node.addChild(this); } } /** Fetch all child Nodes of this Node */ get children() { return this._children; } /** Get current parent of this Node */ get parent() { return this._parent; } /** Is this Node enabled. Disabled nodes will not be rendered */ get enabled() { return this._enabled; } /** Set enabled state of this Node, this will also set all child nodes */ set enabled(enabled) { this._enabled = enabled; this._children.forEach((child) => { child.enabled = enabled; }); } /** Does this Node cast shadows, default true */ get castShadow() { return this._castShadow; } /** Set will this Node cast shadows, this will also set all child nodes */ set castShadow(value) { this._castShadow = value; this._children.forEach((child) => { child.castShadow = value; }); } /** Does this Node receive shadows, default true */ get receiveShadow() { return this._receiveShadow; } /** Set will this Node receive shadows, this will also set all child nodes */ set receiveShadow(value) { this._receiveShadow = value; this._children.forEach((child) => { child.receiveShadow = value; }); } /** Get the physics body for this Node, if there is one */ get physicsBody() { return this._physicsBody; } /** Set the physics body for this Node */ set physicsBody(body) { this._physicsBody = body; } /** * Updates the position & rotation of this node to match it's linked physics Body * This is called automatically by the engine, but can be called manually if needed */ updateFromPhysicsBody() { if (!this._physicsBody) return; this.position = Tuples.fromCannon(this._physicsBody.position); this.setQuaternion(Tuples.fromCannon(this._physicsBody.quaternion)); for (const handler of this.eventHandlers.get(EVENT_POSITION) ?? []) { handler({ position: this.position, rotation: this.getQuaternion(), scale: this.scale, nodeId: this.id }); } } /** * Add an event handler to listen for node changes * @param event NodeEvent type, one of 'position', 'rotation', 'scale' * @param handler Function to call when event is triggered */ addEventHandler(event, handler) { this.eventHandlers.get(event)?.push(handler); } }; function uniqueId() { const dateString = Date.now().toString(36).substring(0, 5); const randomness = Math.random().toString(36).substring(0, 5); return dateString + randomness; } // src/renderable/instance.ts var Instance = class extends Node { /** * Create a new instance of a renderable thing * @param {Renderable} renderable - Renderable to use for this instance */ constructor(renderable) { super(); /** Flip all textures on this instance on the X axis */ this.flipTextureX = false; /** Flip all textures on this instance on the Y axis */ this.flipTextureY = false; this.renderable = renderable; } setPosition(x, y, z) { if (x instanceof Array) { this.position = x; return; } if (y === void 0 || z === void 0) throw new Error("setPosition requires either an array or 3 numbers"); this.position = [x, y, z]; } /** * Render this instance in the world, called internally by the context when rendering * @param {WebGL2RenderingContext} gl - WebGL context to render into * @param {UniformSet} uniforms - Map of uniforms to pass to shader */ render(gl, uniforms, programOverride) { if (!this.enabled) return; if (!this.renderable) return; if (!gl) return; if (!this.customProgramName && programOverride && !this.castShadow) { return; } if (this.customProgramName) { programOverride = ProgramCache.instance.get(this.customProgramName); } const world = this.modelMatrix; uniforms.u_world = world; mat45.invert(uniforms.u_worldInverseTranspose, world); mat45.transpose(uniforms.u_worldInverseTranspose, uniforms.u_worldInverseTranspose); const worldView = mat45.multiply(mat45.create(), uniforms.u_view, world); mat45.multiply(uniforms.u_worldViewProjection, uniforms.u_proj, worldView); uniforms.u_flipTextureX = this.flipTextureX; uniforms.u_flipTextureY = this.flipTextureY; uniforms.u_receiveShadow = this.receiveShadow; if (this.uniformOverrides) uniforms = { ...uniforms, ...this.uniformOverrides }; this.renderable.render(gl, uniforms, this.material, programOverride); } }; // src/renderable/billboard.ts import { mat4 as mat46, vec3 as vec33 } from "gl-matrix"; import * as twgl3 from "twgl.js"; var BillboardType = /* @__PURE__ */ ((BillboardType2) => { BillboardType2[BillboardType2["SPHERICAL"] = 0] = "SPHERICAL"; BillboardType2[BillboardType2["CYLINDRICAL"] = 1] = "CYLINDRICAL"; return BillboardType2; })(BillboardType || {}); var Billboard = class { /** Creates a square billboard */ constructor(gl, type, material, size) { this.type = 1 /* CYLINDRICAL */; this.material = material; this.type = type; const verts = twgl3.primitives.createXYQuadVertices(size, 0, size / 2); for (let i = 1; i < verts.texcoord.length; i += 2) { verts.texcoord[i] = 1 - verts.texcoord[i]; } this.bufferInfo = twgl3.createBufferInfoFromArrays(gl, verts); this.programInfo = ProgramCache.instance.get(ProgramCache.PROG_BILLBOARD); } /** * Render is used draw this billboard, this is called from the Instance that wraps * this renderable */ render(gl, uniforms, materialOverride) { const programInfo = this.programInfo; gl.useProgram(programInfo.program); if (materialOverride === void 0) { this.material.apply(programInfo); } else { materialOverride.apply(programInfo); } const worldView = mat46.multiply(mat46.create(), uniforms.u_view, uniforms.u_world); const scale2 = mat46.getScaling(vec33.create(), worldView); worldView[0] = scale2[0]; worldView[1] = 0; worldView[2] = 0; worldView[8] = 0; worldView[9] = 0; worldView[10] = scale2[2]; if (this.type == 0 /* SPHERICAL */) { worldView[4] = 0; worldView[5] = scale2[1]; worldView[6] = 0; } mat46.multiply(uniforms.u_worldViewProjection, uniforms.u_proj, worldView); twgl3.setBuffersAndAttributes(gl, programInfo, this.bufferInfo); twgl3.setUniforms(programInfo, uniforms); twgl3.drawBufferInfo(gl, this.bufferInfo); Stats.drawCallsPerFrame++; } }; // src/renderable/primitive.ts import * as twgl5 from "twgl.js"; // src/engine/material.ts import * as twgl4 from "twgl.js"; var Material = class _Material { /** * Create a new default material with diffuse white colour, all all default properties */ constructor() { this.additiveBlend = false; this.ambient = [1, 1, 1]; this.diffuse = [1, 1, 1]; this.specular = [0, 0, 0]; this.emissive = [0, 0, 0]; this.shininess = 20; this.opacity = 1; this.reflectivity = 0; this.alphaCutoff = 0; this.diffuseTex = TextureCache.defaultWhite; this.specularTex = TextureCache.defaultWhite; } /** * Create a new material from a raw MTL material. Users are not expected to call this directly as it is used internally by the OBJ parser * @param rawMtl Raw MTL material * @param basePath Base path for locating & loading textures in MTL file * @param filter Apply texture filtering to textures, default: true * @param flipY Flip the Y axis of textures, default: false */ static fromMtl(rawMtl, basePath, filter = true, flipY = false) { const m = new _Material(); m.ambient = rawMtl.ka ? rawMtl.ka : [1, 1, 1]; m.diffuse = rawMtl.kd ? rawMtl.kd : [1, 1, 1]; m.specular = rawMtl.ks ? rawMtl.ks : [0, 0, 0]; m.emissive = rawMtl.ke ? rawMtl.ke : [0, 0, 0]; m.shininess = rawMtl.ns ? rawMtl.ns : 0; m.opacity = rawMtl.d ? rawMtl.d : 1; if (rawMtl.texDiffuse) { m.diffuseTex = TextureCache.instance.getCreate(`${basePath}/${rawMtl.texDiffuse}`, filter, flipY); } if (rawMtl.texSpecular) { m.specularTex = TextureCache.instance.getCreate(`${basePath}/${rawMtl.texSpecular}`, filter, flipY); } if (rawMtl.texNormal) { m.normalTex = TextureCache.instance.getCreate(`${basePath}/${rawMtl.texNormal}`, filter, flipY); } if (rawMtl.illum && rawMtl.illum > 2) { m.reflectivity = (m.specular[0] + m.specular[1] + m.specular[2]) / 3; } return m; } /** * Create a basic Material with a solid/flat diffuse colour * @param r Red component, 0.0 to 1.0 * @param g Green component, 0.0 to 1.0 * @param b Blue component, 0.0 to 1.0 */ static createSolidColour(r, g, b) { const m = new _Material(); m.diffuse = [r, g, b]; return m; } /** * Create a new Material with a texture map loaded from a URL/filepath or Buffer * @param src URL or