commit c039544ec69e5f6a160c5bc4f4fa952b40d0e346 Author: rowan Date: Fri Mar 28 18:06:18 2025 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ddadef --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +http/ +serve + diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f966fee --- /dev/null +++ b/.gitmodules @@ -0,0 +1,11 @@ +[submodule "public/vendor/bitecs"] + path = public/vendor/bitecs + url = https://github.com/NateTheGreatt/bitECS.git + branch = rc-0-4-0 +[submodule "public/vendor/gl-matrix"] + path = public/vendor/gl-matrix + url = https://github.com/toji/gl-matrix.git + branch = v3.4.x +[submodule "public/vendor/kojima"] + path = public/vendor/kojima + url = https://git.kitsu.cafe/rowan/kojima.git diff --git a/examples/cube/index.html b/examples/cube/index.html new file mode 100644 index 0000000..82bf442 --- /dev/null +++ b/examples/cube/index.html @@ -0,0 +1,17 @@ + + + + + + + HTML 5 Boilerplate + + + +
+ +
+ + + + diff --git a/examples/cube/index.js b/examples/cube/index.js new file mode 100644 index 0000000..e769cdc --- /dev/null +++ b/examples/cube/index.js @@ -0,0 +1,205 @@ +import { addComponent, addEntity, createWorld, query } from '/src/ecs.js' +import { Engine, Startup, Update, World } from '/src/core/engine.js' +import { DefaultPlugins } from '/src/plugins/index.js' +import { WebGLContext } from '/src/plugins/renderer/webgl-context.js' +import { Camera, Mesh, Renderable, Transform } from '/src/components/index.js' +import * as twgl from '/public/vendor/twgl/twgl-full.module.js' +import { Assets, AssetType } from '/src/core/assets.js' +import { Geometry, Material, Materials, Meshes } from '/src/plugins/renderer/index.js' + +/** @param {number} degrees */ +const deg2rad = degrees => degrees * Math.PI / 180 + +// INFO: does a camera need a Transform, specifically +// a scale?? + +/** @param {World} world */ +const createCamera = world => { + const entity = addEntity(world) + addComponent(world, entity, [Camera, Transform]) + const transform = twgl.m4.identity() + twgl.m4.translate(transform, [0, 0, 10], transform) + Transform.matrix[entity] = transform + + Camera.projectionMatrix[entity] = [] + Camera.viewMatrix[entity] = [] + Camera.up[entity] = new Float32Array([0, 1, 0]) + Camera.fov[entity] = deg2rad(30) + Camera.zNear[entity] = 0.1 + Camera.zFar[entity] = 1000 + + return entity +} + +const Cube = () => { + const position = [ + // Front face + -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, 0.5, + + // Back face + -0.5, -0.5, -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5, -0.5, + + // Top face + -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, -0.5, + + // Bottom face + -0.5, -0.5, -0.5, 0.5, -0.5, -0.5, 0.5, -0.5, 0.5, -0.5, -0.5, 0.5, + + // Right face + 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, 0.5, + + // Left face + -0.5, -0.5, -0.5, -0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5, + ] + + const indices = [ + // Front face + 0, 1, 2, 0, 2, 3, + + // Back face + 4, 5, 6, 4, 6, 7, + + // Top face + 8, 9, 10, 8, 10, 11, + + // Bottom face + 12, 13, 14, 12, 14, 15, + + // Right face + 16, 17, 18, 16, 18, 19, + + // Left face + 20, 21, 22, 20, 22, 23, + ] + + const color = [ + // Front face (red) + 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, + + // Back face (green) + 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, + + // Top face (blue) + 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, + + // Bottom face (yellow) + 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, + + // Right face (magenta) + 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, + + // Left face (cyan) + 0, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, + ] + + return { + position, + indices, + color: { numComponents: 3, data: color } + } +} + +const Quad = () => { + const position = [ + -0.5, -0.5, 0, + 0.5, -0.5, 0, + 0.5, 0.5, 0, + -0.5, 0.5, 0, + ] + + const indices = [ + 0, 1, 2, + 2, 3, 0, + ] + + const color = [ + 1, 0, 0, + 0, 1, 0, + 0, 0, 1, + 1, 1, 0, + ] + + return { position, indices, color: { numComponents: 3, data: color } } +} + +const Triangle = () => { + return { + position: [-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0], + color: { numComponents: 3, data: [1, 0, 0, 0, 1, 0, 0, 0, 1] }, + indices: [0, 1, 2] + } + +} + +const createObject = (world, mesh, material, position) => { + const entity = addEntity(world) + addComponent(world, entity, [Renderable, Mesh, Transform]) + const transform = twgl.m4.identity() + twgl.m4.translate(transform, position, transform) + Transform.matrix[entity] = transform + + Mesh.geometry[entity] = mesh + Mesh.material[entity] = material + return entity +} + +/** @type {HTMLCanvasElement} */ +const canvas = document.querySelector('main canvas') +if (canvas.getContext) { + const world = createWorld(new World()) + + let app = new Engine(world, canvas) + .addResource(new WebGLContext(canvas)) + .addResource(new Assets()) + .addPlugin(DefaultPlugins) + .addSystem(Startup, async world => { + const gl = world.getResource(WebGLContext).context + const meshes = world.getResource(Meshes) + const materials = world.getResource(Materials) + const assets = world.getResource(Assets) + + const camera = createCamera(world) + + let sources = ['simple-vert.wgsl', 'simple-frag.wgsl'] + .map(url => `/public/shaders/${url}`) + .map(import.meta.resolve) + .map(url => assets.load(url, AssetType.Text)) + + sources = await Promise.all(sources) + + const mesh = Cube() + const geometry = new Geometry(mesh.position, mesh.indices).createBuffers() + + const meshHandle = meshes.add(buffer) + const materialHandle = materials.add(new Material( + programInfo, + { u_color: [1, 1, 1, 1] } + )) + const one = createObject(world, meshHandle, materialHandle, [0, -1.5, 0]) + let transform = Transform.matrix[one] + twgl.m4.rotateX(transform, deg2rad(45), transform) + twgl.m4.rotateY(transform, deg2rad(45), transform) + + const two = createObject(world, meshHandle, materialHandle, [2, 1, 0]) + transform = Transform.matrix[two] + twgl.m4.rotateX(transform, deg2rad(135), transform) + twgl.m4.rotateY(transform, deg2rad(135), transform) + + const three = createObject(world, meshHandle, materialHandle, [-2, 1, 0]) + transform = Transform.matrix[three] + twgl.m4.rotateX(transform, deg2rad(225), transform) + twgl.m4.rotateY(transform, deg2rad(225), transform) + + }) + .addSystem(Update, world => { + const meshes = query(world, [Mesh, Transform]) + for (const mesh of meshes) { + const transform = Transform.matrix[mesh] + twgl.m4.rotateZ(transform, 0.01, transform) + } + }) + .run() +} else { + // canvas not supported +} + diff --git a/index.html b/index.html new file mode 100644 index 0000000..1b36e00 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + HTML 5 Boilerplate + + + +
+ +
+ + + + diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..46518f2 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "es2020", + "target": "es6", + "lib": ["es2019", "dom"], + "types": ["@webgpu/types"], + "checkJs": true, + "paths": { + "/*": ["./*"] + } + }, + "exclude": [ + "node_modules" + ] +} + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2eac66f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "canvas", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@webgpu/types": "^0.1.58" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.58", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.58.tgz", + "integrity": "sha512-+8+NBE17zrc1wS4FvZmmuGTpog5C2H6QC46RY2TTWNpnt15Xvp7dZoUHZQXvb8l5ddKwO8l1pDJa6XTnR4Al1Q==", + "dev": true, + "license": "BSD-3-Clause" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bb6c59b --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@webgpu/types": "^0.1.58" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..38e155a --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,13 @@ +html, body { + width: 100%; + height: 100%; + padding: 0; + margin: 0; +} + +main canvas { + aspect-ratio: 16 / 9; + width: min(100%, 100vh * 16 / 9); + height: 100%; +} + diff --git a/public/shaders/simple-frag.wgsl b/public/shaders/simple-frag.wgsl new file mode 100644 index 0000000..975b432 --- /dev/null +++ b/public/shaders/simple-frag.wgsl @@ -0,0 +1,17 @@ +struct Uniforms { + u_color: vec4f, +}; + +@group(0) @binding(0) +var uniforms: Uniforms; + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) v_color: vec4f, +}; + +@fragment +fn main(input: VertexOutput) -> @location(0) vec4f { + return input.v_color * uniforms.u_color; +} + diff --git a/public/shaders/simple-vert.wgsl b/public/shaders/simple-vert.wgsl new file mode 100644 index 0000000..55854fd --- /dev/null +++ b/public/shaders/simple-vert.wgsl @@ -0,0 +1,27 @@ +struct VertexInput { + @location(0) position: vec4f, + @location(1) color: vec4f, +}; + +struct Uniforms { + u_projection: mat4x4f, + u_view: mat4x4f, + u_world: mat4x4f, +}; + +@group(0) @binding(0) +var uniforms: Uniforms; + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) v_color: vec4f, +}; + +@vertex +fn main(input: VertexInput) -> VertexOutput { + var out: VertexOutput; + out.position = uniforms.u_projection * uniforms.u_view * uniforms.u_world * input.position; + out.v_color = input.color; + return out; +} + diff --git a/public/vendor/bitecs b/public/vendor/bitecs new file mode 160000 index 0000000..caa1f58 --- /dev/null +++ b/public/vendor/bitecs @@ -0,0 +1 @@ +Subproject commit caa1f58be2ccc304c1f0a085de34ca5904b3b80f diff --git a/public/vendor/gl-matrix b/public/vendor/gl-matrix new file mode 160000 index 0000000..4e312f3 --- /dev/null +++ b/public/vendor/gl-matrix @@ -0,0 +1 @@ +Subproject commit 4e312f346713acd87dcb8aef9016121108eac07a diff --git a/public/vendor/kojima b/public/vendor/kojima new file mode 160000 index 0000000..1224a83 --- /dev/null +++ b/public/vendor/kojima @@ -0,0 +1 @@ +Subproject commit 1224a8382cf7ec0e9409430f7185d0f857a2ec26 diff --git a/src/components/color.js b/src/components/color.js new file mode 100644 index 0000000..8f897db --- /dev/null +++ b/src/components/color.js @@ -0,0 +1,9 @@ +import { DefaultSize } from './constants.js' + +export const Rgba8 = { + r: new Uint8Array(DefaultSize), + g: new Uint8Array(DefaultSize), + b: new Uint8Array(DefaultSize), + a: new Uint8Array(DefaultSize), +} + diff --git a/src/components/constants.js b/src/components/constants.js new file mode 100644 index 0000000..0cb928c --- /dev/null +++ b/src/components/constants.js @@ -0,0 +1,2 @@ +export const DefaultSize = 512 + diff --git a/src/components/index.js b/src/components/index.js new file mode 100644 index 0000000..377163d --- /dev/null +++ b/src/components/index.js @@ -0,0 +1,6 @@ +export * from './math.js' +export * from './color.js' +export * from '../plugins/camera/components/index.js' +export * from '../plugins/renderer/components/index.js' +export * from '../plugins/transform/components/index.js' + diff --git a/src/components/math.js b/src/components/math.js new file mode 100644 index 0000000..eed00bc --- /dev/null +++ b/src/components/math.js @@ -0,0 +1,105 @@ +import * as twgl from '../../public/vendor/twgl/twgl-full.module.js' + +const identity = twgl.m4.identity() +const id = (size, m) => { + const len = m.length + for (let i = 0; i < len; i += size) { + m.set(identity, i) + } + return m +} +export const Range = size => ({ + min: new Float32Array(size), + max: new Float32Array(size) +}) + +export const UVector2 = size => [] +export const UVector3 = size => [] + +export const Vector2 = size => [] +export const Vector3 = size => [] + +export const Matrix3x3 = size => [] +export const Matrix4x4 = size => [] + +export class ComponentProxy { + id + _component + + /** + * @param {number} id + * @param {import('../ecs.js').Component} component + */ + constructor(id, component) { + this.id = id + this._component = component + } + + valueOf() { + return this._component[this.id] + } +} + +export class ArrayComponentProxy extends ComponentProxy { + _length + + /** + * @param {number} id + * @param {import('../ecs.js').Component} component + * @param {number} length + */ + constructor(id, component, length) { + super(id, component) + this._length = length + } + + valueOf() { + return this._component.subarray(this.id, this.id + this._length) + } +} + +export class Vector2Proxy extends ArrayComponentProxy { + /* + * @param {number} id + * @param {import('../ecs.js').Component} [component=Vector2] + * @param {number} [length=2] + */ + constructor(id, component = Vector2, length = 2) { + super(id, component, length) + } + + get x() { + return this._component[this.id] + } + + get y() { + return this._component[this.id + 1] + } +} + +export class Vector3Proxy extends Vector2Proxy { + /* + * @param {number} id + * @param {import('../ecs.js').Component} [component=Vector3] + * @param {number} [length=3] + */ + constructor(id, component = Vector3, length = 3) { + super(id, component, length) + } + + get z() { + return this._component[this.id + 2] + } +} + +export class Matrix3x3Proxy extends ArrayComponentProxy { + constructor(id, component = Matrix3x3) { + super(id, component, 9) + } +} + +export class Matrix4x4Proxy extends ArrayComponentProxy { + constructor(id, component = Matrix4x4) { + super(id, component, 16) + } +} diff --git a/src/core/assets.js b/src/core/assets.js new file mode 100644 index 0000000..0ed139f --- /dev/null +++ b/src/core/assets.js @@ -0,0 +1,112 @@ +import { NotImplementedError } from '../utils/error.js' + +export const AssetType = Object.freeze({ + Text: url => fetch(url).then(res => res.text()), + ArrayBuffer: url => fetch(url).then(res => res.arrayBuffer()), + Image: url => new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = err => reject(err) + image.src = url + }) +}) + +class StorageLayer { + has(_key) { + throw new NotImplementedError() + } + + get(_key) { + throw new NotImplementedError() + } + + set(_key, _value) { + throw new NotImplementedError() + } +} + +class MemoryStorage extends StorageLayer { + #memory = new Map() + + has(key) { + return this.#memory.has(key) + } + + get(key) { + return this.#memory.get(key) + } + + set(key, value) { + this.#memory.set(key, value) + } +} + +class LocalStorage extends StorageLayer { + #cache = new MemoryStorage() + + has(key) { + if (this.#cache.has(key)) { + return true + } else { + const item = localStorage.getItem(key) + if (item != null) { + this.#cache.set(key, item) + return true + } + + return false + } + } + + get(key) { + if (this.#cache.has(key)) { + return this.#cache.get(key) + } else { + const item = localStorage.getItem(key) + if (item != null) { + this.#cache.set(key, item) + return item + } + + return null + } + } + + set(key, value) { + this.#cache.set(key, value) + localStorage.setItem(key, value) + } + +} + +export class Assets { + #storage = new LocalStorage() + + /** + * + * @param {UrlLike} url + * @param {(url: UrlLike) => Promise} handler + */ + async load(url, handler = AssetType.ArrayBuffer) { + if (this.#storage.has(url)) { + return this.#storage.get(url) + } + + try { + const data = await handler(url) + this.#storage.set(url, data) + return data + } catch (err) { + console.error(err) + } + } + + /** + * @param {UrlLike} url + */ + get(url) { + return this.#storage.get(url) + } +} + + diff --git a/src/core/ecs.js b/src/core/ecs.js new file mode 100644 index 0000000..354c535 --- /dev/null +++ b/src/core/ecs.js @@ -0,0 +1,24 @@ +import { isObject } from './utils/index.js' +import { addComponent as add } from '../public/vendor/bitecs/index.js' +import { World } from './core/engine.js' +export * from '../public/vendor/bitecs/index.js' + +/** @typedef {import('./core/engine.js').Plugin} Plugin */ +/** @typedef {any} Component + +/** + * @param {World} world + * @param {number} entity + * @param {Component} component + */ +export const addComponent = (world, entity, component) => { + if (Array.isArray(component)) { + const len = component.length + for (let i = 0; i < len; i++) { + add(world, entity, component[i]) + } + } else { + add(world, entity, component) + } +} + diff --git a/src/core/engine.js b/src/core/engine.js new file mode 100644 index 0000000..ef1fce9 --- /dev/null +++ b/src/core/engine.js @@ -0,0 +1,217 @@ +import { addComponent, addEntity } from '../ecs.js' +import { PrioritySchedule, SystemSchedule, Schedules } from './schedule.js' + +/** @typedef {(engine: Engine) => void} Plugin */ +/** @typedef {number} Entity */ + +export class Startup { } +export class Update { } +export class FixedUpdate { } +export class Render { } + +export class Time { + /** @type {DOMHighResTimeStamp} */ + currentTime + + /** @type {DOMHighResTimeStamp} */ + deltaTime +} + +class Resources { + #inner = new Map() + + /** + * @template T + * @param {T} resource + */ + add(resource) { + this.#inner.set(resource.constructor, resource) + } + + /** + * @template T + * @param {new(...args: any[]) => T} resource + * @returns {T} + */ + get(resource) { + return this.#inner.get(resource) + } +} + + +export class World { + #resources = new Resources() + + /** + * @template T + * @param {T} resource + */ + addResource(resource) { + this.#resources.add(resource) + } + + /** + * @template T + * @param {new(...args: any[]) => T} resource + * @returns {T} + */ + getResource(resource) { + return this.#resources.get(resource) + } +} + +export class Canvas { + /** @type {HTMLCanvasElement} */ + #element + + /** + * @param {HTMLCanvasElement} element + */ + constructor(element) { + this.#element = element + } + + get element() { + return this.#element + } +} + +// TODO: add analog to Commands/EntityCommands +export class Engine { + /** @type {World} */ + #world + + /** @type {Plugin[]} */ + #plugins = [] + + #schedules = new Schedules() + + /** @type {number} */ + #loopId + + #initialized = false + + /** + * @param {World} world + * @param {HTMLCanvasElement} canvas + */ + constructor(world, canvas) { + this.#world = world + + this.addResource(new Time()) + this.addResource(new Canvas(canvas)) + + this.#schedules.add( + Startup, + new PrioritySchedule(0, new SystemSchedule()) + ) + + this.#schedules.add( + Update, + new PrioritySchedule(1, new SystemSchedule()) + ) + + this.#schedules.addBefore( + Update, + FixedUpdate, + new SystemSchedule() + ) + + this.#schedules.addAfter( + Update, + Render, + new SystemSchedule() + ) + } + + get world() { + return this.#world + } + + /** + * @param {any} resource + * @returns {Engine} + */ + addResource(resource) { + this.#world.addResource(resource) + return this + } + + /** + * @template T + * @param {new(...args: any[]) => T} resource + * @returns {T} + */ + getResource(resource) { + return this.#world.getResource(resource) + } + + /** + * @param {import('./schedule.js').ScheduleLabel} schedule + * @param {import('./schedule').System} system + * @param {import('./schedule').RegisterSystemOptions} [options={}] + * @returns {Engine} + */ + addSystem(schedule, system, options) { + this.#schedules.get(schedule).register(system, options) + return this + } + + /** + * @param {Plugin | Plugin[]} plugin + * @returns {Engine} + */ + addPlugin(plugin) { + if (Array.isArray(plugin)) { + plugin.forEach(this.addPlugin.bind(this)) + } else { + plugin(this) + } + + return this + } + + stop() { + cancelAnimationFrame(this.#loopId) + this.#loopId = undefined + } + + async run() { + // FIXME: modularize this + const startup = this.#schedules.get(Startup).schedule + const main = this.#schedules.get(Update).schedule + const fixed = this.#schedules.get(FixedUpdate).schedule + const render = this.#schedules.get(Render).schedule + + const fixedTimeStep = 1000 / 60 + + let lastTime = performance.now() + let accumulator = 0 + + /** @type (time: DOMHighResTimeStamp) => void */ + const loop = currentTime => { + const deltaTime = currentTime - lastTime + lastTime = currentTime + accumulator += deltaTime + + const time = this.getResource(Time) + + time.currentTime = currentTime + time.deltaTime = deltaTime + + while (accumulator >= fixedTimeStep) { + fixed.execute(this.#world) + accumulator -= fixedTimeStep + } + + main.execute(this.#world) + render.execute(this.#world) + + this.#loopId = requestAnimationFrame(loop) + } + + await startup.execute(this.#world) + this.#loopId = requestAnimationFrame(loop) + } +} + diff --git a/src/core/schedule.js b/src/core/schedule.js new file mode 100644 index 0000000..54a5cd9 --- /dev/null +++ b/src/core/schedule.js @@ -0,0 +1,329 @@ +import { PriorityMap } from '../utils/ordered-map.js' + +/** @typedef {Function} ScheduleLabel */ + +/** + * @template Dependency + */ +export class CyclicalDependencyError extends Error { + /** + * @param {Dependency} dependency + */ + constructor(dependency) { + super(`Cyclical dependency: ${dependency}`) + } +} + +/** + * @template Dependency + */ +export class UnknownDependencyError extends Error { + /** + * @param {Dependency} dependency + */ + constructor(dependency) { + super(`Dependency ${dependency} not found`) + } +} + + +/** + * @template Node + */ +export class Visitor { + #nodes + #recursion + #visited + + /** + * Create a Visitor + * @param {Iterable.} nodes + */ + constructor(nodes) { + this.#nodes = Array.from(nodes) + this.#visited = new Set() + this.#recursion = new Set() + } + + /** @typedef {(node: Node) => void} VisitNode */ + /** @typedef {(node: Node, visit: VisitNode) => void} HandleNode */ + + /** + * @param {Node} node + * @param {HandleNode} onVisit + * + * @throws {CircularDependencyError} + * Thrown if two nodes rely on one another + */ + _visitNode(node, onVisit) { + if (this.#recursion.has(node)) { + throw new CyclicalDependencyError(node) + } + + if (this.#visited.has(node)) return + + this.#visited.add(node) + this.#recursion.add(node) + + onVisit(node, node => this._visitNode(node, onVisit)) + + this.#recursion.delete(node) + } + + /** + * Visit all nodes with the onVisit callback + * + * @param {HandleNode} onVisit + * @returns {Node[]} + */ + visit(onVisit) { + const len = this.#nodes.length + + for (let i = 0; i < len; i++) { + this._visitNode(this.#nodes[i], onVisit) + } + + return Array.from(this.#visited.values()) + } + + /** + * Reset the visitor + */ + clear() { + this.#visited.clear() + this.#recursion.clear() + } +} + +export class Schedule { + execute(world) { + throw new NotImplementedError() + } +} + +/** @typedef {(world: any) => void} System */ +/** + * @typedef {Object} SystemSchema + * @property {System} system + * @property {Set} dependencies + * @property {Set} before + * @property {Set} after + */ + +/** + * + * @typedef {Object} RegisterSystemOptions + * @property {System[]} [options.dependencies = []] + * @property {System} [options.before] + * @property {System} [options.after] + */ + +export class SystemSchedule { + /** @type {Map} */ + _graph = new Map() + + /** @type {System[]?} */ + _cache = undefined + + /** + * @param {System} node + * @returns {SystemSchema} + * + * @throws {UnknownDependencyError} + * Thrown if the requested node is not in the dependency graph + */ + _tryGet(node) { + if (!this._graph.has(node)) { + throw new UnknownDependencyError(node) + } + + return this._graph.get(node) + } + + /** + * @param {SystemSchema} beforeNode + * @param {SystemSchema} afterNode + */ + _insert(beforeNode, afterNode) { + afterNode.before.add(beforeNode.system) + beforeNode.after.add(afterNode.system) + } + + /** + * Register a new system with this schedule + * + * @param {System} system + * @param {RegisterSystemOptions} [options={}] + */ + register(system, { dependencies = [], before, after } = {}) { + this._cache = undefined + + this._graph.set(system, { system, dependencies: new Set(dependencies), before: new Set(), after: new Set() }) + + const systemNode = this._graph.get(system) + + for (const dep of dependencies) { + const depNode = this._tryGet(dep) + depNode.after.add(system) + } + + if (before) { + const beforeNode = this._tryGet(before) + this._insert(systemNode, beforeNode) + } + + if (after) { + const afterNode = this._tryGet(after) + this._insert(afterNode, systemNode) + } + } + + /** + * @returns {System[]} + * + * @throws {UnknownDependencyError} + * Thrown if a dependency was not registered before resolution + * + * @throws {CircularDependencyError} + * Thrown if two nodes rely on one another + */ + resolve() { + if (this._cache) { + return this._cache + } + + const visitor = new Visitor(this._graph.keys()) + + this._cache = visitor.visit( + (next, visit) => { + const node = this._graph.get(next) + + for (const dep of node.dependencies) { + visit(dep) + } + + for (const before of node.before) { + visit(before) + } + } + ) + + + return this._cache + } + + /** + * @param {any} world + * + * @throws {UnknownDependencyError} + * Thrown if a dependency was not registered before resolution + * + * @throws {CircularDependencyError} + * Thrown if two nodes rely on one another + */ + async execute(world) { + const systems = this.resolve() + const len = systems.length + for (let i = 0; i < len; i++) { + await systems[i](world) + } + } +} + +export class PrioritySchedule { + /** @type {Number} */ + priority + + /** @type {SystemSchedule} */ + schedule + + /** + * @param {Number} priority + * @param {SystemSchedule} schedule + */ + constructor(priority, schedule) { + this.priority = priority + this.schedule = schedule + } + + register(system, options) { + this.schedule.register(system, options) + } + + async execute(world) { + await this.schedule.execute(world) + } +} + +/** + * @param {[ScheduleLabel, PrioritySchedule]} value + * @return {number} + */ +const getPriority = value => value[1].priority + +/** + * @extends PriorityMap + */ +export class Schedules extends PriorityMap { + /** @type {Number} */ + #lowest + + /** @type {Number} */ + #highest + + constructor() { + super(getPriority) + + this.#lowest = -Infinity + this.#highest = Infinity + } + + /** + * @param {ScheduleLabel} target + * @param {ScheduleLabel} label + * @param {SystemSchedule} schedule + */ + addBefore(target, label, schedule) { + const targetSchedule = this.get(target) + let priority = this.#lowest + + if (targetSchedule) { + priority = targetSchedule.priority - 1 + } + + this.add(label, new PrioritySchedule(priority, schedule)) + } + + /** + * @param {ScheduleLabel} target + * @param {ScheduleLabel} label + * @param {SystemSchedule} schedule + */ + addAfter(target, label, schedule) { + const targetSchedule = this.get(target) + let priority = this.#lowest + + if (targetSchedule) { + priority = targetSchedule.priority + 1 + } + + this.add(label, new PrioritySchedule(priority, schedule)) + } + + /** + * @param {ScheduleLabel} label + * @param {PrioritySchedule} prioritySchedule + */ + add(label, prioritySchedule) { + const priority = prioritySchedule.priority + + if (priority > this.#lowest) { + this.#lowest = priority + } else if (priority < this.#highest) { + this.#highest = priority + } + + this.set(label, prioritySchedule) + } +} + diff --git a/src/mixins/index.js b/src/mixins/index.js new file mode 100644 index 0000000..92a761b --- /dev/null +++ b/src/mixins/index.js @@ -0,0 +1,4 @@ +export const mixin = (target, mixin) => { + Object.assign(target, mixin) +} + diff --git a/src/plugins/camera/components/index.js b/src/plugins/camera/components/index.js new file mode 100644 index 0000000..4e7e6cc --- /dev/null +++ b/src/plugins/camera/components/index.js @@ -0,0 +1,78 @@ +import { DefaultSize } from '../../../components/constants.js' +import { Vector3, Matrix4x4 } from '../../../components/math.js' + +class RenderGraph { + #nodes = new Map() + #edges = new Map() + + add(name, render, dependencies = []) { + this.node.set(name, render) + this.edges.set(name, dependencies) + } + + update(world) { + const executed = new Set() + const order = this.sort() + + if (!order) { + return + } + + for (const node of order) { + const render = this.nodes.get(node) + render(world) + executed.add(node) + } + } + + sort() { + const visited = new Set() + const stack = [] + const cycles = new Set() + + const visit = node => { + if (cycles.has(node)) { + return false + } + + if (visited.has(node)) { + return true + } + + visited.add(node) + cycles.add(node) + + for (const dep of this.dependentsFor(name)) { + if (!visit(dep)) { + return false + } + } + + cycles.delete(name) + stack.push(node) + } + + for (const node in this.nodes) { + if (!visited.has(node) && !visit(node)) { + return + } + } + + return stack.reverse() + } + + dependentsFor(name) { + return this.edges.values() + .filter(deps => deps.includes(name)) + } +} + +export const Camera = { + projectionMatrix: Matrix4x4(), + viewMatrix: Matrix4x4(), + fov: new Float32Array(DefaultSize), + zNear: new Float32Array(DefaultSize), + zFar: new Float32Array(DefaultSize), + up: Vector3(), +} + diff --git a/src/plugins/camera/index.js b/src/plugins/camera/index.js new file mode 100644 index 0000000..931a01d --- /dev/null +++ b/src/plugins/camera/index.js @@ -0,0 +1,9 @@ +import { Engine, Update } from '../../core/engine.js' +import { updateCamera } from './systems/index.js' + +/** @param {Engine} engine */ +export const CameraPlugin = engine => { + engine.addSystem(Update, updateCamera(engine.world)) + console.log('hi from camera plugin!') +} + diff --git a/src/plugins/camera/render-graph.js b/src/plugins/camera/render-graph.js new file mode 100644 index 0000000..3bbe2d2 --- /dev/null +++ b/src/plugins/camera/render-graph.js @@ -0,0 +1,3 @@ +class RenderGraph { + +} diff --git a/src/plugins/camera/systems/index.js b/src/plugins/camera/systems/index.js new file mode 100644 index 0000000..3603ed5 --- /dev/null +++ b/src/plugins/camera/systems/index.js @@ -0,0 +1,39 @@ +import { World } from '../../../core/engine.js' +import { query } from '../../../ecs.js' +import { Transform } from '../../transform/components/index.js' +import { Camera } from '../components/index.js' +import { WebGLContext } from '../../renderer/webgl-context.js' +import * as twgl from '/public/vendor/twgl/twgl-full.module.js' + +/** @param {World} world */ +export const updateCamera = world => { + /** @type {WebGLContext} */ + const glContext = world.getResource(WebGLContext) + + /** @type {HTMLCanvasElement} */ + // @ts-ignore + const canvas = glContext.context.canvas + + /** @param {World} world */ + return world => { + const cameras = query(world, [Camera, Transform]) + const len = cameras.length + + for (let i = 0; i < len; i++) { + const entity = cameras[i] + + const projection = Camera.projectionMatrix[entity] + const transform = Transform[entity] + const view = Camera.viewMatrix[entity] + + const fov = Camera.fov[entity] + const aspect = canvas.clientWidth / canvas.clientHeight + const zNear = Camera.zNear[entity] + const zFar = Camera.zFar[entity] + + twgl.m4.perspective(fov, aspect, zNear, zFar, projection) + twgl.m4.inverse(transform, view) + } + } +} + diff --git a/src/plugins/index.js b/src/plugins/index.js new file mode 100644 index 0000000..5d6ce28 --- /dev/null +++ b/src/plugins/index.js @@ -0,0 +1,16 @@ +import { Engine } from '../core/engine.js' +import { CameraPlugin } from './camera/index.js' +import { RendererPlugin } from './renderer/index.js' +import { TransformPlugin } from './transform/index.js' +import { WindowPlugin } from './window/index.js' + +/** @param {Engine} engine */ +export const DefaultPlugins = engine => { + engine.addPlugin([ + CameraPlugin, + RendererPlugin, + TransformPlugin, + WindowPlugin + ]) +} + diff --git a/src/plugins/renderer/components/index.js b/src/plugins/renderer/components/index.js new file mode 100644 index 0000000..80456f6 --- /dev/null +++ b/src/plugins/renderer/components/index.js @@ -0,0 +1,8 @@ +import { DefaultSize } from '/src/components/constants.js' + +export const Renderable = {} +export const Mesh = { + geometry: new Uint8Array(DefaultSize), + material: new Uint8Array(DefaultSize), +} + diff --git a/src/plugins/renderer/index.js b/src/plugins/renderer/index.js new file mode 100644 index 0000000..3b63e24 --- /dev/null +++ b/src/plugins/renderer/index.js @@ -0,0 +1,10 @@ +import { Engine, Render } from '../../core/engine.js' +import { render } from './systems/index.js' + +/** + * @param {Engine} engine + */ +export const RendererPlugin = engine => { + engine.addSystem(Render, render(engine.world)) +} + diff --git a/src/plugins/renderer/mesh/index.js b/src/plugins/renderer/mesh/index.js new file mode 100644 index 0000000..ff41f52 --- /dev/null +++ b/src/plugins/renderer/mesh/index.js @@ -0,0 +1,4 @@ +import { Union } from '/public/vendor/kojima/union.js' + +export const Indices = Union(['U16', 'U32']) + diff --git a/src/plugins/renderer/mesh/mesh.js b/src/plugins/renderer/mesh/mesh.js new file mode 100644 index 0000000..fb6edb2 --- /dev/null +++ b/src/plugins/renderer/mesh/mesh.js @@ -0,0 +1,65 @@ +import { MeshAttributeData } from './vertex.js' +import { Indices } from './index.js' +import { assignOptions } from '/src/utils/index.js' + +export const PrimitiveTopology = Object.freeze({ + PointList: 'point-list', + LineList: 'line-list', + LineStrip: 'line-strip', + TriangleList: 'triangle-list', + TriangleStrip: 'triangle-strip' +}) + +export const IndexFormat = Object.freeze({ + Uint16: 'uint16', + Uint32: 'uint32' +}) + +export const FrontFace = Object.freeze({ + Ccw: 'ccw', + Cw: 'cw' +}) + +export const CullMode = Object.freeze({ + None: 'none', + Front: 'front', + Back: 'back' +}) + +export class PrimitiveState { + #topology = PrimitiveTopology.TriangleList + #stripIndexFormat + #frontFace = FrontFace.Ccw + #cullMode = CullMode.None + + #unclippedDepth = false + + /** + * @param {GPUIndexFormat} stripIndexFormat + * @param {Object} options + * @param {GPUPrimitiveTopology} [options.topology] + * @param {GPUFrontFace} [options.frontFace] + * @param {GPUCullMode} [options.cullMode] + * @param {boolean} [options.unclippedDepth] + */ + constructor(stripIndexFormat, options) { + this.#stripIndexFormat = stripIndexFormat + assignOptions(this, options) + } + + get topology() { return this.#topology } + get stripIndexFormat() { return this.#stripIndexFormat } + get frontFace() { return this.#frontFace } + get cullMode() { return this.#cullMode } + get unclippedDepth() { return this.#unclippedDepth } +} + +export class Mesh { + /** @type {PrimitiveTopology} */ + topology + /** @type {Map} */ + attributes = new Map() + /** @type {Indices} */ + indices +} + diff --git a/src/plugins/renderer/mesh/vertex-format.js b/src/plugins/renderer/mesh/vertex-format.js new file mode 100644 index 0000000..e236d31 --- /dev/null +++ b/src/plugins/renderer/mesh/vertex-format.js @@ -0,0 +1,116 @@ +// https://www.w3.org/TR/webgpu/#enumdef-gpuvertexformat + +import { Union } from '/public/vendor/kojima/union.js' +import { capitalizen } from '/src/utils/index' + +/** @typedef {Uint8ArrayConstructor | Int8ArrayConstructor | Uint16ArrayConstructor | Int16ArrayConstructor | Uint32ArrayConstructor | Int32ArrayConstructor | Float32ArrayConstructor | Float64ArrayConstructor} TypedArrayConstructor */ + +/** @type {GPUVertexFormat[]} */ +export const GPUVertexFormats = ['uint8', 'uint8x2', 'uint8x4', 'sint8', 'sint8x2', 'sint8x4', 'unorm8', 'unorm8x2', 'unorm8x4', 'snorm8', 'snorm8x2', 'snorm8x4', 'uint16', 'uint16x2', 'uint16x4', 'sint16', 'sint16x2', 'sint16x4', 'unorm16', 'unorm16x2', 'unorm16x4', 'snorm16', 'snorm16x2', 'snorm16x4', 'float16', 'float16x2', 'float16x4', 'float32', 'float32x2', 'float32x3', 'float32x4', 'uint32', 'uint32x2', 'uint32x3', 'uint32x4', 'sint32', 'sint32x2', 'sint32x3', 'sint32x4', 'unorm10-10-10-2', 'unorm8x4-bgra'] + +export class MeshAttributeData { + #format + #buffer + + /** + * @param {VertexFormat} format + * @param {ArrayBufferLike} buffer + */ + constructor(format, buffer) { + this.#format = format + this.#buffer = buffer + } + + get format() { return this.#format } + get buffer() { return this.#buffer } +} + +export class VertexFormat { + #name + #components + #size + #bufferType + + /** + * @param {GPUVertexFormat} name + * @param {number} components + * @param {number} size + * @param {TypedArrayConstructor} bufferType + */ + constructor(name, components, size, bufferType) { + this.#name = name + this.#components = components + this.#size = size + this.#bufferType = bufferType + } + + get name() { return this.#name } + get components() { return this.#components } + get size() { return this.#size } + get bufferType() { return this.#bufferType } +} + + +// INFO: ugly initialization code for VertexFormat statics +// this is for doing VertexFormat.Uint8 to get that format +(() => { + + /** @type {[GPUVertexFormat, number, number, TypedArrayConstructor][]} */ + const formats = [ + ['uint8', 1, 1, Uint8Array], + ['uint8x2', 2, 2, Uint8Array], + ['uint8x4', 4, 4, Uint8Array], + ['sint8', 1, 1, Int8Array], + ['sint8x2', 2, 2, Int8Array], + ['sint8x4', 4, 4, Int8Array], + ['unorm8', 1, 1, Uint8Array], + ['unorm8x2', 2, 2, Uint8Array], + ['unorm8x4', 4, 4, Uint8Array], + ['snorm8', 1, 1, Int8Array], + ['snorm8x2', 2, 2, Int8Array], + ['snorm8x4', 4, 4, Int8Array], + ['uint16', 1, 2, Uint16Array], + ['uint16x2', 2, 4, Uint16Array], + ['uint16x4', 4, 8, Uint16Array], + ['sint16', 1, 2, Int16Array], + ['sint16x2', 2, 4, Int16Array], + ['sint16x4', 4, 8, Int16Array], + ['unorm16', 1, 2, Uint16Array], + ['unorm16x2', 2, 4, Uint16Array], + ['unorm16x4', 4, 8, Uint16Array], + ['snorm16', 1, 2, Int16Array], + ['snorm16x2', 2, 4, Int16Array], + ['snorm16x4', 4, 8, Int16Array], + ['float16', 1, 2, Uint16Array], + ['float16x2', 2, 4, Uint16Array], + ['float16x4', 4, 8, Uint16Array], + ['float32', 1, 4, Float32Array], + ['float32x2', 2, 8, Float32Array], + ['float32x3', 3, 12, Float32Array], + ['float32x4', 4, 16, Float32Array], + ['uint32', 1, 4, Uint32Array], + ['uint32x2', 2, 8, Uint32Array], + ['uint32x3', 3, 12, Uint32Array], + ['uint32x4', 4, 16, Uint32Array], + ['sint32', 1, 4, Int32Array], + ['sint32x2', 2, 8, Int32Array], + ['sint32x3', 3, 12, Int32Array], + ['sint32x4', 4, 16, Int32Array], + ['unorm10-10-10-2', 4, 4, Uint32Array], + ['unorm8x4-bgra', 4, 4, Uint8Array] + ] + + /** + * @param {string} name + */ + const capitalize = name => name.charAt(0).toUpperCase() + name.slice(1) + + formats.forEach(([name, components, size]) => { + Object.defineProperty(VertexFormat, capitalize(value.name), { + value: new VertexFormat(name, components, size), + writable: false, + configurable: false + }) + }) +})() + diff --git a/src/plugins/renderer/mesh/vertex.js b/src/plugins/renderer/mesh/vertex.js new file mode 100644 index 0000000..1ea7a7f --- /dev/null +++ b/src/plugins/renderer/mesh/vertex.js @@ -0,0 +1,213 @@ +import { VertexFormat } from './vertex-format.js' + +/** + * @typedef {number} MeshVertexAttributeId + * @typedef {number} BufferAddress + */ + +export class VertexAttribute { + #format + #offset + #shaderLocation + + /** + * @param {VertexFormat} format + * @param {number} offset + * @param {number} shaderLocation + */ + constructor(format, offset, shaderLocation) { + this.#format = format + this.#offset = offset + this.#shaderLocation = shaderLocation + } + + get format() { return this.#format } + get offset() { return this.#offset } + get shaderLocation() { return this.#shaderLocation } +} + +export class MeshVertexAttribute { + #name + #id + #format + + /** + * @param {string} name + * @param {MeshVertexAttributeId} id + * @param {VertexFormat} format + */ + constructor(name, id, format) { + this.#name = name + this.#id = id + this.#format = format + } + + get name() { return this.#name } + get id() { return this.#id } + get format() { return this.#format } +} + +export class VertexAttributeDescriptor { + #id + #name + #shaderLocation + + /** + * @param {MeshVertexAttributeId} id + * @param {string} name + * @param {number} location + */ + constructor(id, name, location) { + this.#id = id + this.#name = name + this.#shaderLocation = location + } + + get id() { return this.#id } + get name() { return this.#name } + get shaderLocation() { return this.#shaderLocation } +} + +class VertexAttributeError extends Error { + /** + * @param {MeshVertexAttributeId} id + * @param {string} name + * @param {string} message + */ + constructor(id, name, message) { + super(message) + this.id = id + this.name = name + } + + /** + * @param {MeshVertexAttributeId} id + * @param {string} name + */ + static missing(id, name) { + return new VertexAttributeError(id, name, `Missing vertex attribute ${name} (id: ${id})`) + } +} + +export class VertexBufferLayout { + #stride + #stepMode + #attributes + + /** + * @param {BufferAddress} stride + * @param {GPUVertexStepMode} stepMode + * @param {VertexAttribute[]} attributes + */ + constructor(stride, stepMode, attributes) { + this.#stride = stride + this.#stepMode = stepMode + this.#attributes = attributes + } + + get stride() { return this.#stride } + get stepMode() { return this.#stepMode } + get attributes() { return this.#attributes } + + /** + * @param {GPUVertexStepMode} mode + * @param {VertexFormat[]} formats + */ + static fromVertexFormats(mode, formats) { + let offset = 0 + const attributes = [] + + const len = formats.length + for (let i = 0; i < len; i++) { + const format = formats[i] + + attributes.push(new VertexAttribute( + format, + offset, + i + )) + + offset += format.size + } + + return new VertexBufferLayout( + offset, + mode, + attributes + ) + } +} + +export class MeshVertexBufferLayout { + #ids + #layout + + /** + * @param {Set} ids + * @param {VertexBufferLayout} layout + */ + constructor(ids, layout) { + this.#ids = ids + this.#layout = layout + } + + get attributeIds() { return this.#ids } + get layout() { return this.#layout } + + /** + * @param {MeshVertexAttributeId} id + */ + has(id) { + return this.#ids.has(id) + } + + /** + * @param {VertexAttributeDescriptor[]} descriptors + */ + getLayout(descriptors) { + let attributes = [] + + const len = descriptors.length + for (let i = 0; i < len; i++) { + const desc = descriptors[i] + const index = descriptors.indexOf(desc) + + if (index <= -1) { + throw VertexAttributeError.missing(desc.id, desc.name) + } + + const attr = this.#layout.attributes[index] + attributes.push(new VertexAttribute( + attr.format, + attr.offset, + desc.shaderLocation + )) + } + + return new VertexBufferLayout( + this.#layout.stride, + this.#layout.stepMode, + attributes + ) + } +} + +// TODO: revisit this to either optimize the sparse array, +// model it more like Bevy's tagged union, or refactor +// it entirely to better suit JS +export class MeshAttributeData { + /** @type {MeshVertexAttribute} */ + #attribute + /** @type {number[]} */ + #values + + /** + * @param {MeshVertexAttribute} attribute + * @param {number[]} values + */ + constructor(attribute, values) { + this.#attribute = attribute + this.#values = values + } +} + diff --git a/src/plugins/renderer/systems/index.js b/src/plugins/renderer/systems/index.js new file mode 100644 index 0000000..e4663ce --- /dev/null +++ b/src/plugins/renderer/systems/index.js @@ -0,0 +1,9 @@ +/** + * @param {World} world + */ +export const render = world => { + /** @param {World} world */ + return world => { + } +} + diff --git a/src/plugins/renderer/webgl-context.js b/src/plugins/renderer/webgl-context.js new file mode 100644 index 0000000..84ced91 --- /dev/null +++ b/src/plugins/renderer/webgl-context.js @@ -0,0 +1,36 @@ + +export class WebGLContext { + /** @type {WebGL2RenderingContext} */ + #context + + /** + * @param {HTMLCanvasElement} canvas + */ + constructor(canvas) { + this.#context = canvas.getContext('webgl2') + + if (!this.#context) { + throw new Error('WebGL2 is unsupported') + } + } + + get context() { + return this.#context + } + + get clearColor() { + return this.#context.getParameter(this.#context.COLOR_CLEAR_VALUE) + } + + /** + * @param {[number, number, number, number]} value + */ + set clearColor(value) { + this.#context.clearColor(...value) + } + + clear() { + + } +} + diff --git a/src/plugins/renderer/webgpu-context.js b/src/plugins/renderer/webgpu-context.js new file mode 100644 index 0000000..255fb4a --- /dev/null +++ b/src/plugins/renderer/webgpu-context.js @@ -0,0 +1,427 @@ +export class WebGPUError extends Error { + /** + * @param {string} message + */ + constructor(message) { + super(message) + } + + static unsupported() { + return new WebGPUError("WebGPU is unsupported in this browser") + } + + static adapterUnavailable() { + return new WebGPUError("Could not request a WebGPU adapter.") + } +} + +export class WebGPUBuilderError extends Error { + /** + * @param {string} message + */ + constructor(message) { + super(message) + } + + /** + * @param {string} property + */ + static missing(property) { + return new WebGPUBuilderError(`Missing required property: ${property}`) + } +} + +class GPUCanvasConfigurationBuilder { + /** @type {WebGPUContextBuilder} */ + #contextBuilder + /** @type {GPUTextureUsageFlags} */ + #usage = 0x10 + /** @type {GPUTextureFormat[]} */ + #viewFormats = [] + /** @type {PredefinedColorSpace} */ + #colorSpace = 'srgb' + /** @type {GPUCanvasToneMapping} */ + #toneMapping = {} + /** @type {GPUCanvasAlphaMode} */ + #alphaMode = 'opaque' + + /** + * @param {WebGPUContextBuilder} builder + */ + constructor(builder) { + this.#contextBuilder = builder + } + + /** + * @param {GPUTextureUsageFlags} usage + */ + usage(usage) { + this.#usage = usage + return this + } + + /** + * @param {GPUTextureUsageFlags} usage + */ + addUsage(usage) { + this.#usage = this.#usage | usage + return this + } + + /** + * @param {GPUTextureFormat[]} formats + */ + viewFormats(formats) { + this.#viewFormats = formats + return this + } + + /** + * @param {GPUTextureFormat} format + */ + addViewFormat(format) { + this.#viewFormats.push(format) + return this + } + + /** + * @param {PredefinedColorSpace} space + */ + colorSpace(space) { + this.#colorSpace = space + return this + } + + /** + * @param {GPUCanvasToneMappingMode} mode + */ + toneMappingMode(mode) { + this.#toneMapping = { mode } + return this + } + + /** + * @param {GPUCanvasAlphaMode} mode + */ + alphaMode(mode) { + this.#alphaMode = mode + return this + } + + /** + * @returns {GPUCanvasBuilderConfiguration} + */ + build() { + return { + usage: this.#usage, + viewFormats: this.#viewFormats, + colorSpace: this.#colorSpace, + toneMapping: this.#toneMapping, + alphaMode: this.#alphaMode + } + } + + /** + * @returns {WebGPUContextBuilder} + */ + apply() { + return this.#contextBuilder.configureCanvas(this.build()) + } +} + +/** @typedef {'core' | 'compatibility'} GPUFeatureLevel */ + +class GPUAdapterOptionsBuilder { + #contextBuilder + /** @type {GPUFeatureLevel} */ + #featureLevel = 'core' + /** @type {GPUPowerPreference} */ + #powerPreference + /** @type {boolean} */ + #forceFallbackAdapter = false + /** @type {boolean} */ + #xrCompatible = false + + /** + * @param {WebGPUContextBuilder} builder + */ + constructor(builder) { + this.#contextBuilder = builder + } + + /** + * @param {GPUFeatureLevel} featureLevel + */ + featureLevel(featureLevel) { + this.#featureLevel = featureLevel + return this + } + + /** + * @param {GPUPowerPreference?} preference + */ + powerPreference(preference) { + this.#powerPreference = preference + return this + } + + /** + * @param {boolean} force + */ + forceFallbackAdapter(force) { + this.#forceFallbackAdapter = force + return this + } + + /** + * @param {boolean} compatible + */ + xrCompatible(compatible) { + this.#xrCompatible = compatible + return this + } + + /** + * @returns {GPURequestAdapterOptions} + */ + build() { + return { + featureLevel: this.#featureLevel, + powerPreference: this.#powerPreference, + forceFallbackAdapter: this.#forceFallbackAdapter, + xrCompatible: this.#xrCompatible + } + } + + apply() { + return this.#contextBuilder.adapter(this.build()) + } +} + +class GPUDeviceDescriptorBuilder { + /** @type {WebGPUContextBuilder} */ + #contextBuilder + + /** @type {GPUFeatureName[]} */ + #features = [] + + /** @type {Record} */ + #limits = {} + + /** + * @param {WebGPUContextBuilder} builder + */ + constructor(builder) { + this.#contextBuilder = builder + } + + /** + * @param {GPUFeatureName[]} features + */ + requiredFeatures(features) { + this.#features = features + return this + } + + /** + * @param {GPUFeatureName} feature + */ + addRequiredFeature(feature) { + this.#features.push(feature) + return this + } + + /** + * @param {Record} limits + */ + requiredLimits(limits) { + this.#limits = limits + return this + } + + /** + * @param {string} key + * @param {GPUSize64?} value + */ + setRequiredLimit(key, value) { + this.#limits[key] = value + return this + } + + /** + * @returns {GPUDeviceDescriptor} + */ + build() { + return { + requiredFeatures: this.#features, + requiredLimits: this.#limits + } + } + + apply() { + return this.#contextBuilder.device(this.build()) + } +} + +/** + * @typedef {Object} GPUCanvasBuilderConfiguration + * @property {GPUTextureUsageFlags} [usage=0x10] + * @property {GPUTextureFormat[]} [viewFormats=[]] + * @property {PredefinedColorSpace} [colorSpace='srgb'] + * @property {GPUCanvasToneMapping} [toneMapping = {}] + * @property {GPUCanvasAlphaMode} [alphaMode='opaque'] + */ + +export class WebGPUContextBuilder { + /** @type {Promise} */ + #adapter + + /** @type {GPUCanvasBuilderConfiguration} */ + #canvasConfig + + /** @type {GPUCanvasContext} */ + #context + + /** @type {Promise} */ + #device + + /** @type {number} */ + #dpr = window.devicePixelRatio || 1 + + /** + * @param {string} type + */ + #warnDefault(type) { + console.warn(`WARN: Requesting WebGPU ${type} with default options.`) + } + + /** + * @param {GPURequestAdapterOptions} [descriptor] + * @returns {WebGPUContextBuilder} + * @throws {WebGPUError} Throws if WebGPU is not supported + */ + adapter(descriptor) { + if (!navigator.gpu) { + throw WebGPUError.unsupported() + } + + this.#adapter = navigator.gpu.requestAdapter(descriptor) + return this + } + + buildAdapterConfiguration() { + return new GPUAdapterOptionsBuilder(this) + } + + /** + * @param {HTMLCanvasElement} canvas + * @returns {WebGPUContextBuilder} + */ + context(canvas) { + this.#context = canvas.getContext('webgpu') + return this + } + + buildCanvasConfiguration() { + return new GPUCanvasConfigurationBuilder(this) + } + + /** + * @param {GPUDeviceDescriptor} [descriptor] + * @returns {WebGPUContextBuilder} + * @throws {WebGPUError} Throws if WebGPU is not supported + */ + device(descriptor) { + if (!this.#adapter) { + this.#warnDefault('adapter') + this.adapter() + } + + this.#device = this.#adapter.then(adapter => adapter.requestDevice(descriptor)) + + return this + } + + buildDeviceDescriptor() { + return new GPUDeviceDescriptorBuilder(this) + } + + /** + * @param {GPUCanvasBuilderConfiguration} [configuration] + * @returns {WebGPUContextBuilder} + */ + configureCanvas(configuration) { + this.#canvasConfig = configuration + return this + } + + /** + * @returns {Promise} + * @throws {WebGPUBuilderError} Throws if context is undefined or if WebGPU is unsupported + */ + async build() { + if (!this.#context) { + throw WebGPUBuilderError.missing('context') + } + + if (!this.#device) { + this.#warnDefault('device') + this.device() + } + + const [adapter, device] = await Promise.all([this.#adapter, this.#device]) + + const format = navigator.gpu.getPreferredCanvasFormat() + + if (this.#canvasConfig) { + this.#context.configure({ + device, + format, + ...this.#canvasConfig + }) + } + + return new WebGPUContext( + this.#context, + adapter, + device, + format, + this.#dpr + ) + } +} + +export class WebGPUContext { + #adapter + #context + #device + #format + #dpr + + /** + * @param {GPUCanvasContext} context + * @param {GPUAdapter} adapter + * @param {GPUDevice} device + * @param {GPUTextureFormat} format + * @param {number} [dpr] + */ + constructor(context, adapter, device, format, dpr) { + this.#context = context + this.#adapter = adapter + this.#device = device + this.#format = format + this.#dpr = dpr || window.devicePixelRatio || 1 + } + + get adapter() { return this.#adapter } + get context() { return this.#context } + get device() { return this.#device } + get queue() { return this.#device.queue } + get format() { return this.#format } + get devicePixelRatio() { return this.#dpr } + + static create() { + return new WebGPUContextBuilder() + } +} + diff --git a/src/plugins/transform/components/index.js b/src/plugins/transform/components/index.js new file mode 100644 index 0000000..09a16af --- /dev/null +++ b/src/plugins/transform/components/index.js @@ -0,0 +1,15 @@ +import { Matrix4x4 } from '../../../components/math.js' +import { createRelation } from '/src/ecs.js' + +// Local transform, relative to parent +export const Transform = { + matrix: Matrix4x4() + +} + +export const GlobalTransform = { + matrix: Matrix4x4() +} + +export const ChildOf = createRelation({ autoRemoveSubject: true, exclusive: true }) + diff --git a/src/plugins/transform/index.js b/src/plugins/transform/index.js new file mode 100644 index 0000000..91c8831 --- /dev/null +++ b/src/plugins/transform/index.js @@ -0,0 +1,8 @@ +import { Dirty, Transform } from './components/index.js' +import { updateTransform } from './systems/index.js' +import { addComponent, observe, onSet } from '/src/ecs.js' + +export const TransformPlugin = engine => { + console.log('hi from transform plugin!') +} + diff --git a/src/plugins/transform/systems/index.js b/src/plugins/transform/systems/index.js new file mode 100644 index 0000000..3cc2f56 --- /dev/null +++ b/src/plugins/transform/systems/index.js @@ -0,0 +1,13 @@ +import { ChildOf, GlobalTransform, Transform } from '../components/index.js' +import { getRelationTargets, Not, query, Wildcard } from '/src/ecs.js' +import * as twgl from '/public/vendor/twgl/twgl-full.module.js' +import { World } from '/src/core/engine.js' + +/** @param {World} _world */ +export const updateTransform = _world => { + /** @param {World} world */ + return world => { + // TODO: implement transform propagation with GlobalTransform and Transform + } +} + diff --git a/src/plugins/window/index.js b/src/plugins/window/index.js new file mode 100644 index 0000000..8f25348 --- /dev/null +++ b/src/plugins/window/index.js @@ -0,0 +1,10 @@ +import { Engine, Startup } from '/src/core/engine.js' +import { resize } from './systems/index.js' + +/** + * @param {Engine} engine + */ +export const WindowPlugin = engine => { + engine.addSystem(Startup, resize) +} + diff --git a/src/plugins/window/resize.js b/src/plugins/window/resize.js new file mode 100644 index 0000000..99709cf --- /dev/null +++ b/src/plugins/window/resize.js @@ -0,0 +1,99 @@ +const ResizeObserverBox = Object.freeze({ + ContentBox: 'content-box', + BorderBox: 'border-box', + DevicePixelContentBox: 'device-pixel-content-box', +}) + +export class ResizeHandler { + /** @type {HTMLCanvasElement} */ + #canvas + + /** @type {ResizeObserver} */ + #observer + + /** + * @typedef {(width: number, height: number) => void} Subscriber + * @type {Set} + */ + #subscribers = new Set() + + /** + * @param {HTMLCanvasElement} canvas + * @param {number} dpr + * @param {number} limit + */ + constructor(canvas, dpr, limit) { + this.#canvas = canvas + + this.#observer = new ResizeObserver(entries => { + for (const entry of entries) { + /** @type {HTMLCanvasElement} */ + // @ts-ignore + const canvas = entry.target + + const size = this._getSize(entry)[0] + let width = size.inlineSize * dpr + let height = size.blockSize * dpr + width = this._clamp(width, 1, limit) + height = this._clamp(height, 1, limit) + + if (width !== canvas.width || height !== canvas.height) { + canvas.width = width + canvas.height = height + this.#notify(width, height) + } + } + }) + } + + /** + * @param {Subscriber} fn + */ + subscribe(fn) { + this.#subscribers.add(fn) + } + + /** + * @param {number} width + * @param {number} height + */ + #notify(width, height) { + this.#subscribers + .forEach(fn => fn(width, height)) + } + + /** + * @param {Subscriber} fn + */ + unsubscribe(fn) { + this.#subscribers.delete(fn) + } + + start() { + this.#observer.observe( + this.#canvas, + { box: ResizeObserverBox.DevicePixelContentBox } + ) + + } + + stop() { + this.#observer.unobserve(this.#canvas) + } + + /** + * @param {ResizeObserverEntry} entry + */ + _getSize(entry) { + return entry.devicePixelContentBoxSize || entry.contentBoxSize + } + + /** + * @param {number} size + * @param {number} min + * @param {number} max + */ + _clamp(size, min, max) { + return Math.max(min, Math.min(size, max)) + } +} diff --git a/src/plugins/window/systems/index.js b/src/plugins/window/systems/index.js new file mode 100644 index 0000000..673e697 --- /dev/null +++ b/src/plugins/window/systems/index.js @@ -0,0 +1,21 @@ +import { WebGPUContext } from '../../renderer/webgpu-context.js' +import { ResizeHandler } from '../resize.js' +import { World } from '/src/core/engine.js' + +/** + * @param {World} world + */ +export const resize = world => { + const webgpu = world.getResource(WebGPUContext) + const resizer = new ResizeHandler( + // @ts-ignore + webgpu.context.canvas, + webgpu.devicePixelRatio, + webgpu.device.limits.maxTextureDimension2D + ) + + resizer.start() + + world.addResource(resizer) +} + diff --git a/src/utils/error.js b/src/utils/error.js new file mode 100644 index 0000000..aae6b77 --- /dev/null +++ b/src/utils/error.js @@ -0,0 +1,9 @@ +export class NotImplementedError extends Error { + /** + * @param {string} message + */ + constructor(message) { + super(message) + } +} + diff --git a/src/utils/index.js b/src/utils/index.js new file mode 100644 index 0000000..0084407 --- /dev/null +++ b/src/utils/index.js @@ -0,0 +1,28 @@ +export const capitalizen = (n, str) => { + str.slice(0, n).toUpperCase() + str.slice(n) +} + +/** + * @param {Object} a + * @param {Object} b + */ +export const assignOptions = (a, b) => { + const props = Object.entries(b) + .map(([key, value]) => [`#${key}`, value]) + + Object.assign(a, Object.fromEntries(props)) +} +export const trampoline = (f, ...args) => { + let x = f(...args) + + while (typeof x === 'function') { + x = x() + } + + return x +} + +export const isObject = value => { + return Object.getPrototypeOf(value) === Object.prototype +} + diff --git a/src/utils/ordered-map.js b/src/utils/ordered-map.js new file mode 100644 index 0000000..68c24d4 --- /dev/null +++ b/src/utils/ordered-map.js @@ -0,0 +1,105 @@ +/** @typedef {-1 | 0 | 1} Ternary */ +/** + * @template T + * @typedef {(a: T, b: T) => Ternary | NaN} Comparer + */ + +/** + * @template X, Y + * @param {X | Y} a + * @param {Y} b + * @returns [X | undefined, Y | undefined] + */ +const optionalSecond = (a, b) => { + let x = a + let y = b + + if (!b) { + x = undefined + y = b + } + + return [x, y] +} + +/** + * @template K, V + */ +export class OrderedMap extends Map { + _sort + + /** + * @overload + * @param {Iterable.<[K, V]>} iterable + * @param {Comparer<[K, V]>} comparer + */ + /** + * @overload + * @param {Comparer<[K, V]>} comparer + */ + /** + * @param {Iterable.<[K, V]> | Comparer<[K, V]>} iterable + * @param {Comparer<[K, V]>} [comparer] + */ + constructor(iterable, comparer) { + const [iter, sort] = optionalSecond(iterable, comparer) + + /** @ts-ignore */ + super(iter) + this._sort = sort + } + + /** + * @param {Comparer<[K, V]>} comparer + */ + sortWith(comparer) { + this._sort = comparer + } + + *[Symbol.iterator]() { + /** @ts-ignore */ + yield* [...this.entries()].sort(this._sort) + } +} + +/** + * @template T + * @param {(value: T) => number} get + * @returns {Comparer} + */ +const prioritySort = get => (a, b) => get(a) - get(b) + +/** + * @template K, V + */ +export class PriorityMap extends OrderedMap { + /** + * @typedef {(value: [K, V]) => number} PriorityGetter + */ + + /** @type PriorityGetter */ + _getter + + /** + * @overload + * @param {Iterable.<[K, V]>} iterable + * @param {PriorityGetter} getter + */ + /** + * @overload + * @param {PriorityGetter} getter + */ + /** + * @param {Iterable.<[K, V]> | PriorityGetter} iterable + * @param {PriorityGetter} [getter] + */ + constructor(iterable, getter) { + const [iter, get] = optionalSecond(iterable, getter) + + /** @ts-ignore */ + super(iter, prioritySort(get)) + + /** @ts-ignore */ + this._getter = get + } +} diff --git a/src/utils/webgpu.js b/src/utils/webgpu.js new file mode 100644 index 0000000..f7941e1 --- /dev/null +++ b/src/utils/webgpu.js @@ -0,0 +1,9 @@ +export class Attribute { + #name + #format + #location + #buffer + #count + #components +} +