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
JavaScript
// 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