From 85ccdbb0a6154c96c896e85a616f4b9032356815 Mon Sep 17 00:00:00 2001 From: Daan Vanden Bosch Date: Sun, 19 Jan 2020 17:16:28 +0100 Subject: [PATCH] Added experimental WebGL renderer. --- src/application/index.ts | 2 +- src/core/gui/RendererWidget.ts | 2 +- src/core/math.ts | 24 --- src/core/math/index.ts | 60 +++++++ src/core/rendering/Mesh.ts | 162 ++++++++++++++++++ src/core/rendering/Renderer.ts | 134 +-------------- src/core/rendering/Scene.ts | 54 ++++++ src/core/rendering/ShaderProgram.ts | 91 ++++++++++ src/core/rendering/Texture.ts | 72 ++++++++ src/core/rendering/ThreeRenderer.ts | 142 +++++++++++++++ src/core/rendering/Transform.ts | 33 ++++ src/core/rendering/VertexFormat.ts | 27 +++ src/core/rendering/WebglRenderer.ts | 119 +++++++++++++ src/core/rendering/shader_sources.ts | 55 ++++++ src/index.ts | 2 +- .../gui/QuestEditorRendererView.ts | 2 +- .../gui/QuestRunnerRendererView.ts | 2 +- src/quest_editor/index.ts | 2 +- .../rendering/EntityImageRenderer.ts | 2 +- src/quest_editor/rendering/QuestRenderer.ts | 4 +- src/viewer/gui/TextureView.ts | 4 +- src/viewer/index.ts | 39 +++-- src/viewer/rendering/ModelRenderer.ts | 4 +- src/viewer/rendering/TextureRenderer.ts | 4 +- src/viewer/rendering/WebglTextureRenderer.ts | 98 +++++++++++ test/src/core/rendering/StubThreeRenderer.ts | 2 +- 26 files changed, 961 insertions(+), 181 deletions(-) delete mode 100644 src/core/math.ts create mode 100644 src/core/math/index.ts create mode 100644 src/core/rendering/Mesh.ts create mode 100644 src/core/rendering/Scene.ts create mode 100644 src/core/rendering/ShaderProgram.ts create mode 100644 src/core/rendering/Texture.ts create mode 100644 src/core/rendering/ThreeRenderer.ts create mode 100644 src/core/rendering/Transform.ts create mode 100644 src/core/rendering/VertexFormat.ts create mode 100644 src/core/rendering/WebglRenderer.ts create mode 100644 src/core/rendering/shader_sources.ts create mode 100644 src/viewer/rendering/WebglTextureRenderer.ts diff --git a/src/application/index.ts b/src/application/index.ts index 1377323c..13e09b3f 100644 --- a/src/application/index.ts +++ b/src/application/index.ts @@ -5,7 +5,7 @@ import { create_item_type_stores } from "../core/stores/ItemTypeStore"; import { create_item_drop_stores } from "../hunt_optimizer/stores/ItemDropStore"; import { ApplicationView } from "./gui/ApplicationView"; import { throttle } from "lodash"; -import { DisposableThreeRenderer } from "../core/rendering/Renderer"; +import { DisposableThreeRenderer } from "../core/rendering/ThreeRenderer"; import { Disposer } from "../core/observable/Disposer"; import { disposable_custom_listener, disposable_listener } from "../core/gui/dom"; import { Random } from "../core/Random"; diff --git a/src/core/gui/RendererWidget.ts b/src/core/gui/RendererWidget.ts index a3be5f99..35cc432b 100644 --- a/src/core/gui/RendererWidget.ts +++ b/src/core/gui/RendererWidget.ts @@ -1,7 +1,7 @@ import { ResizableWidget } from "./ResizableWidget"; -import { Renderer } from "../rendering/Renderer"; import { div } from "./dom"; import { Widget } from "./Widget"; +import { Renderer } from "../rendering/Renderer"; export class RendererWidget extends ResizableWidget { readonly element = div({ className: "core_RendererWidget" }); diff --git a/src/core/math.ts b/src/core/math.ts deleted file mode 100644 index 2f7910cf..00000000 --- a/src/core/math.ts +++ /dev/null @@ -1,24 +0,0 @@ -const TO_DEG = 180 / Math.PI; -const TO_RAD = 1 / TO_DEG; - -/** - * Converts radians to degrees. - */ -export function rad_to_deg(rad: number): number { - return rad * TO_DEG; -} - -/** - * Converts degrees to radians. - */ -export function deg_to_rad(deg: number): number { - return deg * TO_RAD; -} - -/** - * @returns the floored modulus of its arguments. The computed value will have the same sign as the - * `divisor`. - */ -export function floor_mod(dividend: number, divisor: number): number { - return ((dividend % divisor) + divisor) % divisor; -} diff --git a/src/core/math/index.ts b/src/core/math/index.ts new file mode 100644 index 00000000..d4787768 --- /dev/null +++ b/src/core/math/index.ts @@ -0,0 +1,60 @@ +import { assert } from "../util"; + +const TO_DEG = 180 / Math.PI; +const TO_RAD = 1 / TO_DEG; + +/** + * Converts radians to degrees. + */ +export function rad_to_deg(rad: number): number { + return rad * TO_DEG; +} + +/** + * Converts degrees to radians. + */ +export function deg_to_rad(deg: number): number { + return deg * TO_RAD; +} + +/** + * @returns the floored modulus of its arguments. The computed value will have the same sign as the + * `divisor`. + */ +export function floor_mod(dividend: number, divisor: number): number { + return ((dividend % divisor) + divisor) % divisor; +} + +export class Matrix4 { + static of(...values: readonly number[]): Matrix4 { + return new Matrix4(new Float32Array(values)); + } + + static identity(): Matrix4 { + // prettier-ignore + return Matrix4.of( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ) + } + + constructor(readonly data: Float32Array) { + assert(data.length === 16, "values should be of length 16."); + } +} + +export function matrix4_product(a: Matrix4, b: Matrix4): Matrix4 { + const array = new Float32Array(16); + + for (let i = 0; i < 4; i++) { + for (let j = 0; j < 4; j++) { + for (let k = 0; k < 4; k++) { + array[i * 4 + j] += a.data[i * 4 + k] * b.data[k * 4 + j]; + } + } + } + + return new Matrix4(array); +} diff --git a/src/core/rendering/Mesh.ts b/src/core/rendering/Mesh.ts new file mode 100644 index 00000000..755eb008 --- /dev/null +++ b/src/core/rendering/Mesh.ts @@ -0,0 +1,162 @@ +import { + GL, + vertex_format_size, + vertex_format_tex_offset, + VERTEX_POS_LOC, + VERTEX_TEX_LOC, + VertexFormat, +} from "./VertexFormat"; +import { assert } from "../util"; +import { Texture } from "./Texture"; + +export class Mesh { + private readonly index_count: number; + private vao: WebGLVertexArrayObject | null = null; + private vertex_buffer: WebGLBuffer | null = null; + private index_buffer: WebGLBuffer | null = null; + private uploaded = false; + + constructor( + readonly format: VertexFormat, + private readonly vertex_data: ArrayBuffer, + private readonly index_data: ArrayBuffer, + readonly texture?: Texture, + ) { + this.index_count = index_data.byteLength / 2; + } + + upload(gl: GL): void { + if (this.uploaded) return; + + try { + this.vao = gl.createVertexArray(); + if (this.vao == null) throw new Error("Failed to create VAO."); + + this.vertex_buffer = gl.createBuffer(); + if (this.vertex_buffer == null) throw new Error("Failed to create vertex buffer."); + + this.index_buffer = gl.createBuffer(); + if (this.index_buffer == null) throw new Error("Failed to create index buffer."); + + gl.bindVertexArray(this.vao); + + // Vertex data. + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertex_buffer); + gl.bufferData(gl.ARRAY_BUFFER, this.vertex_data, gl.STATIC_DRAW); + + const vertex_size = vertex_format_size(this.format); + + gl.vertexAttribPointer(VERTEX_POS_LOC, 3, gl.FLOAT, true, vertex_size, 0); + gl.enableVertexAttribArray(VERTEX_POS_LOC); + + const tex_offset = vertex_format_tex_offset(this.format); + + if (tex_offset !== -1) { + gl.vertexAttribPointer( + VERTEX_TEX_LOC, + 2, + gl.UNSIGNED_SHORT, + true, + vertex_size, + tex_offset, + ); + gl.enableVertexAttribArray(VERTEX_TEX_LOC); + } + + gl.bindBuffer(gl.ARRAY_BUFFER, null); + + // Index data. + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.index_buffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, this.index_data, gl.STATIC_DRAW); + + gl.bindVertexArray(null); + + this.texture?.upload(gl); + + this.uploaded = true; + } catch (e) { + gl.deleteVertexArray(this.vao); + this.vao = null; + gl.deleteBuffer(this.vertex_buffer); + this.vertex_buffer = null; + gl.deleteBuffer(this.index_buffer); + this.index_buffer = null; + throw e; + } + } + + render(gl: GL): void { + gl.bindVertexArray(this.vao); + + gl.drawElements(gl.TRIANGLES, this.index_count, gl.UNSIGNED_SHORT, 0); + + gl.bindVertexArray(null); + } + + delete(gl: GL): void { + gl.deleteVertexArray(this.vao); + gl.deleteBuffer(this.vertex_buffer); + gl.deleteBuffer(this.index_buffer); + } +} + +export class MeshBuilder { + private readonly vertex_data: { + x: number; + y: number; + z: number; + u?: number; + v?: number; + }[] = []; + private readonly index_data: number[] = []; + private _texture?: Texture; + + constructor(private readonly format: VertexFormat) {} + + vertex(x: number, y: number, z: number, u?: number, v?: number): this { + switch (this.format) { + case VertexFormat.PosTex: + assert( + u != undefined && v != undefined, + `Vertex format ${VertexFormat[this.format]} requires texture coordinates.`, + ); + break; + } + + this.vertex_data.push({ x, y, z, u, v }); + return this; + } + + triangle(v1: number, v2: number, v3: number): this { + this.index_data.push(v1, v2, v3); + return this; + } + + texture(tex: Texture): this { + this._texture = tex; + return this; + } + + build(): Mesh { + const v_size = vertex_format_size(this.format); + const v_tex_offset = vertex_format_tex_offset(this.format); + const v_data = new ArrayBuffer(this.vertex_data.length * v_size); + const v_view = new DataView(v_data); + let i = 0; + + for (const { x, y, z, u, v } of this.vertex_data) { + v_view.setFloat32(i, x, true); + v_view.setFloat32(i + 4, y, true); + v_view.setFloat32(i + 8, z, true); + + if (v_tex_offset !== -1) { + v_view.setUint16(i + v_tex_offset, u! * 0xffff, true); + v_view.setUint16(i + v_tex_offset + 2, v! * 0xffff, true); + } + + i += v_size; + } + + return new Mesh(this.format, v_data, new Uint16Array(this.index_data), this._texture); + } +} diff --git a/src/core/rendering/Renderer.ts b/src/core/rendering/Renderer.ts index 1e3b81ac..42c3eb8d 100644 --- a/src/core/rendering/Renderer.ts +++ b/src/core/rendering/Renderer.ts @@ -1,137 +1,13 @@ -import CameraControls from "camera-controls"; -import * as THREE from "three"; -import { - Clock, - Color, - Group, - HemisphereLight, - OrthographicCamera, - PerspectiveCamera, - Scene, - Vector2, - Vector3, -} from "three"; import { Disposable } from "../observable/Disposable"; -CameraControls.install({ - // Hack to make panning and orbiting work the way we want. - THREE: { - ...THREE, - MOUSE: { ...THREE.MOUSE, LEFT: THREE.MOUSE.RIGHT, RIGHT: THREE.MOUSE.LEFT }, - }, -}); - -export interface DisposableThreeRenderer extends THREE.Renderer, Disposable {} - export abstract class Renderer implements Disposable { - private _debug = false; + abstract readonly canvas_element: HTMLCanvasElement; - get debug(): boolean { - return this._debug; - } + abstract dispose(): void; - set debug(debug: boolean) { - this._debug = debug; - } + abstract start_rendering(): void; - abstract readonly camera: PerspectiveCamera | OrthographicCamera; - readonly controls!: CameraControls; - readonly scene = new Scene(); - readonly light_holder = new Group(); + abstract stop_rendering(): void; - private readonly renderer: DisposableThreeRenderer; - private render_scheduled = false; - private animation_frame_handle?: number = undefined; - private readonly light = new HemisphereLight(0xffffff, 0x505050, 1.0); - private readonly controls_clock = new Clock(); - private readonly size = new Vector2(0, 0); - - protected constructor(three_renderer: DisposableThreeRenderer) { - this.renderer = three_renderer; - this.renderer.domElement.tabIndex = 0; - this.renderer.domElement.addEventListener("mousedown", this.on_mouse_down); - this.renderer.domElement.style.outline = "none"; - - this.scene.background = new Color(0x181818); - this.light_holder.add(this.light); - this.scene.add(this.light_holder); - } - - get canvas_element(): HTMLCanvasElement { - return this.renderer.domElement; - } - - set_size(width: number, height: number): void { - this.size.set(width, height); - this.renderer.setSize(width, height); - this.schedule_render(); - } - - pointer_pos_to_device_coords(pos: Vector2): void { - pos.set((pos.x / this.size.width) * 2 - 1, (pos.y / this.size.height) * -2 + 1); - } - - start_rendering(): void { - if (this.animation_frame_handle == undefined) { - this.schedule_render(); - this.animation_frame_handle = requestAnimationFrame(this.call_render); - } - } - - stop_rendering(): void { - if (this.animation_frame_handle != undefined) { - cancelAnimationFrame(this.animation_frame_handle); - this.animation_frame_handle = undefined; - } - } - - schedule_render = (): void => { - this.render_scheduled = true; - }; - - reset_camera(position: Vector3, look_at: Vector3): void { - this.controls.setLookAt( - position.x, - position.y, - position.z, - look_at.x, - look_at.y, - look_at.z, - ); - } - - dispose(): void { - this.renderer.dispose(); - this.controls.dispose(); - } - - protected init_camera_controls(): void { - (this.controls as CameraControls) = new CameraControls( - this.camera, - this.renderer.domElement, - ); - this.controls.dampingFactor = 1; - this.controls.draggingDampingFactor = 1; - } - - protected render(): void { - this.renderer.render(this.scene, this.camera); - } - - private on_mouse_down = (e: Event): void => { - if (e.currentTarget) (e.currentTarget as HTMLElement).focus(); - }; - - private call_render = (): void => { - const controls_updated = this.controls.update(this.controls_clock.getDelta()); - const should_render = this.render_scheduled || controls_updated; - - this.render_scheduled = false; - - if (should_render) { - this.render(); - } - - this.animation_frame_handle = requestAnimationFrame(this.call_render); - }; + abstract set_size(width: number, height: number): void; } diff --git a/src/core/rendering/Scene.ts b/src/core/rendering/Scene.ts new file mode 100644 index 00000000..70300727 --- /dev/null +++ b/src/core/rendering/Scene.ts @@ -0,0 +1,54 @@ +import { Mesh } from "./Mesh"; +import { IdentityTransform, Transform } from "./Transform"; +import { GL } from "./VertexFormat"; + +export class Scene { + readonly root_node = new Node(undefined, new IdentityTransform()); + + constructor(private readonly gl: GL) {} + + /** + * Creates a new node with `node` as parent. Takes ownership of `mesh`. + * + * @param node - The parent node. + * @param mesh - The new node's mesh. + * @param transform - The new node's transform. + */ + add_child(node: Node, mesh: Mesh, transform: Transform): this { + node.children.push(new Node(mesh, transform)); + mesh.upload(this.gl); + return this; + } + + /** + * Deletes all GL objects related to this scene and resets the scene. + */ + delete(): void { + this.traverse(node => { + node.mesh?.texture?.delete(this.gl); + node.mesh?.delete(this.gl); + node.mesh = undefined; + }, undefined); + + this.root_node.children.splice(0); + this.root_node.transform = new IdentityTransform(); + } + + traverse(f: (node: Node, data: T) => T, data: T): void { + this.traverse_node(this.root_node, f, data); + } + + private traverse_node(node: Node, f: (node: Node, data: T) => T, data: T): void { + const child_data = f(node, data); + + for (const child of node.children) { + this.traverse_node(child, f, child_data); + } + } +} + +export class Node { + readonly children: Node[] = []; + + constructor(public mesh: Mesh | undefined, public transform: Transform) {} +} diff --git a/src/core/rendering/ShaderProgram.ts b/src/core/rendering/ShaderProgram.ts new file mode 100644 index 00000000..86ffcbae --- /dev/null +++ b/src/core/rendering/ShaderProgram.ts @@ -0,0 +1,91 @@ +import { Matrix4 } from "../math"; +import { GL, VERTEX_POS_LOC, VERTEX_TEX_LOC } from "./VertexFormat"; +import { Texture } from "./Texture"; + +export class ShaderProgram { + private readonly gl: GL; + private readonly program: WebGLProgram; + private readonly transform_loc: WebGLUniformLocation; + private readonly tex_sampler_loc: WebGLUniformLocation | null; + + constructor(gl: GL, vertex_source: string, frag_source: string) { + this.gl = gl; + const program = gl.createProgram(); + if (program == null) throw new Error("Failed to create program."); + this.program = program; + + let vertex_shader: WebGLShader | null = null; + let frag_shader: WebGLShader | null = null; + + try { + vertex_shader = create_shader(gl, gl.VERTEX_SHADER, vertex_source); + gl.attachShader(program, vertex_shader); + + frag_shader = create_shader(gl, gl.FRAGMENT_SHADER, frag_source); + gl.attachShader(program, frag_shader); + + gl.bindAttribLocation(program, VERTEX_POS_LOC, "pos"); + gl.bindAttribLocation(program, VERTEX_TEX_LOC, "tex"); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(program); + throw new Error("Shader linking failed. Program log:\n" + log); + } + + const transform_loc = gl.getUniformLocation(program, "transform"); + if (transform_loc == null) throw new Error("Couldn't get transform uniform location."); + this.transform_loc = transform_loc; + + this.tex_sampler_loc = gl.getUniformLocation(program, "tex_sampler"); + + gl.detachShader(program, vertex_shader); + gl.detachShader(program, frag_shader); + } catch (e) { + gl.deleteProgram(program); + throw e; + } finally { + // Always delete shaders after we're done. + gl.deleteShader(vertex_shader); + gl.deleteShader(frag_shader); + } + } + + set_transform(matrix: Matrix4): void { + this.gl.uniformMatrix4fv(this.transform_loc, true, matrix.data); + } + + set_texture(texture: Texture): void { + const gl = this.gl; + gl.uniform1i(this.tex_sampler_loc, 0); + } + + bind(): void { + this.gl.useProgram(this.program); + } + + unbind(): void { + this.gl.useProgram(null); + } + + delete(): void { + this.gl.deleteProgram(this.program); + } +} + +function create_shader(gl: GL, type: GLenum, source: string): WebGLShader { + const shader = gl.createShader(type); + if (shader == null) throw new Error(`Failed to create shader of type ${type}.`); + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + + throw new Error("Vertex shader compilation failed. Shader log:\n" + log); + } + + return shader; +} diff --git a/src/core/rendering/Texture.ts b/src/core/rendering/Texture.ts new file mode 100644 index 00000000..9c4d7f53 --- /dev/null +++ b/src/core/rendering/Texture.ts @@ -0,0 +1,72 @@ +import { GL } from "./VertexFormat"; + +export enum TextureFormat { + RGBA_S3TC_DXT1, + RGBA_S3TC_DXT3, +} + +export class Texture { + private uploaded = false; + private texture: WebGLTexture | null = null; + + constructor( + private readonly width: number, + private readonly height: number, + private readonly format: TextureFormat, + private readonly data: ArrayBuffer, + ) {} + + upload(gl: GL): void { + if (this.uploaded) return; + + const ext = gl.getExtension("WEBGL_compressed_texture_s3tc"); + + if (!ext) { + throw new Error("Extension WEBGL_compressed_texture_s3tc not supported."); + } + + const texture = gl.createTexture(); + if (texture == null) throw new Error("Failed to create texture."); + this.texture = texture; + + let gl_format: GLenum; + + switch (this.format) { + case TextureFormat.RGBA_S3TC_DXT1: + gl_format = ext.COMPRESSED_RGBA_S3TC_DXT1_EXT; + break; + case TextureFormat.RGBA_S3TC_DXT3: + gl_format = ext.COMPRESSED_RGBA_S3TC_DXT3_EXT; + break; + } + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.compressedTexImage2D( + gl.TEXTURE_2D, + 0, + gl_format, + this.width, + this.height, + 0, + new Uint8Array(this.data), + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.bindTexture(gl.TEXTURE_2D, null); + + this.uploaded = true; + } + + bind(gl: GL): void { + gl.bindTexture(gl.TEXTURE_2D, this.texture); + } + + unbind(gl: GL): void { + gl.bindTexture(gl.TEXTURE_2D, null); + } + + delete(gl: GL): void { + gl.deleteTexture(this.texture); + } +} diff --git a/src/core/rendering/ThreeRenderer.ts b/src/core/rendering/ThreeRenderer.ts new file mode 100644 index 00000000..64358b7c --- /dev/null +++ b/src/core/rendering/ThreeRenderer.ts @@ -0,0 +1,142 @@ +import CameraControls from "camera-controls"; +import * as THREE from "three"; +import { + Clock, + Color, + Group, + HemisphereLight, + OrthographicCamera, + PerspectiveCamera, + Scene, + Vector2, + Vector3, +} from "three"; +import { Disposable } from "../observable/Disposable"; +import { Renderer } from "./Renderer"; + +CameraControls.install({ + // Hack to make panning and orbiting work the way we want. + THREE: { + ...THREE, + MOUSE: { ...THREE.MOUSE, LEFT: THREE.MOUSE.RIGHT, RIGHT: THREE.MOUSE.LEFT }, + }, +}); + +export interface DisposableThreeRenderer extends THREE.Renderer, Disposable {} + +/** + * Uses THREE.js for rendering. + */ +export abstract class ThreeRenderer extends Renderer { + private _debug = false; + + get debug(): boolean { + return this._debug; + } + + set debug(debug: boolean) { + this._debug = debug; + } + + abstract readonly camera: PerspectiveCamera | OrthographicCamera; + readonly controls!: CameraControls; + readonly scene = new Scene(); + readonly light_holder = new Group(); + + private readonly renderer: DisposableThreeRenderer; + private render_scheduled = false; + private animation_frame_handle?: number = undefined; + private readonly light = new HemisphereLight(0xffffff, 0x505050, 1.0); + private readonly controls_clock = new Clock(); + private readonly size = new Vector2(0, 0); + + protected constructor(three_renderer: DisposableThreeRenderer) { + super(); + this.renderer = three_renderer; + this.renderer.domElement.tabIndex = 0; + this.renderer.domElement.addEventListener("mousedown", this.on_mouse_down); + this.renderer.domElement.style.outline = "none"; + + this.scene.background = new Color(0x181818); + this.light_holder.add(this.light); + this.scene.add(this.light_holder); + } + + get canvas_element(): HTMLCanvasElement { + return this.renderer.domElement; + } + + set_size(width: number, height: number): void { + this.size.set(width, height); + this.renderer.setSize(width, height); + this.schedule_render(); + } + + pointer_pos_to_device_coords(pos: Vector2): void { + pos.set((pos.x / this.size.width) * 2 - 1, (pos.y / this.size.height) * -2 + 1); + } + + start_rendering(): void { + if (this.animation_frame_handle == undefined) { + this.schedule_render(); + this.animation_frame_handle = requestAnimationFrame(this.call_render); + } + } + + stop_rendering(): void { + if (this.animation_frame_handle != undefined) { + cancelAnimationFrame(this.animation_frame_handle); + this.animation_frame_handle = undefined; + } + } + + schedule_render = (): void => { + this.render_scheduled = true; + }; + + reset_camera(position: Vector3, look_at: Vector3): void { + this.controls.setLookAt( + position.x, + position.y, + position.z, + look_at.x, + look_at.y, + look_at.z, + ); + } + + dispose(): void { + this.renderer.dispose(); + this.controls.dispose(); + } + + protected init_camera_controls(): void { + (this.controls as CameraControls) = new CameraControls( + this.camera, + this.renderer.domElement, + ); + this.controls.dampingFactor = 1; + this.controls.draggingDampingFactor = 1; + } + + protected render(): void { + this.renderer.render(this.scene, this.camera); + } + + private on_mouse_down = (e: Event): void => { + if (e.currentTarget) (e.currentTarget as HTMLElement).focus(); + }; + + private call_render = (): void => { + const controls_updated = this.controls.update(this.controls_clock.getDelta()); + const should_render = this.render_scheduled || controls_updated; + + this.render_scheduled = false; + + if (should_render) { + this.render(); + } + + this.animation_frame_handle = requestAnimationFrame(this.call_render); + }; +} diff --git a/src/core/rendering/Transform.ts b/src/core/rendering/Transform.ts new file mode 100644 index 00000000..c698759e --- /dev/null +++ b/src/core/rendering/Transform.ts @@ -0,0 +1,33 @@ +import { Matrix4 } from "../math"; + +export interface Transform { + readonly matrix4: Matrix4; +} + +export class TranslateTransform implements Transform { + readonly matrix4: Matrix4; + + constructor(x: number, y: number, z: number) { + // prettier-ignore + this.matrix4 = Matrix4.of( + 1, 0, 0, x, + 0, 1, 0, y, + 0, 0, 1, z, + 0, 0, 0, 1, + ); + } +} + +export class IdentityTransform implements Transform { + readonly matrix4: Matrix4; + + constructor() { + // prettier-ignore + this.matrix4 = Matrix4.of( + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1, + ); + } +} diff --git a/src/core/rendering/VertexFormat.ts b/src/core/rendering/VertexFormat.ts new file mode 100644 index 00000000..1b790fb7 --- /dev/null +++ b/src/core/rendering/VertexFormat.ts @@ -0,0 +1,27 @@ +export type GL = WebGL2RenderingContext; + +export enum VertexFormat { + Pos, + PosTex, +} + +export const VERTEX_POS_LOC = 0; +export const VERTEX_TEX_LOC = 1; + +export function vertex_format_size(format: VertexFormat): number { + switch (format) { + case VertexFormat.Pos: + return 12; + case VertexFormat.PosTex: + return 16; + } +} + +export function vertex_format_tex_offset(format: VertexFormat): number { + switch (format) { + case VertexFormat.Pos: + return -1; + case VertexFormat.PosTex: + return 12; + } +} diff --git a/src/core/rendering/WebglRenderer.ts b/src/core/rendering/WebglRenderer.ts new file mode 100644 index 00000000..63ab3e32 --- /dev/null +++ b/src/core/rendering/WebglRenderer.ts @@ -0,0 +1,119 @@ +import { Renderer } from "./Renderer"; +import { Matrix4, matrix4_product } from "../math"; +import { ShaderProgram } from "./ShaderProgram"; +import { GL } from "./VertexFormat"; +import { Scene } from "./Scene"; +import { + POS_FRAG_SHADER_SOURCE, + POS_TEX_FRAG_SHADER_SOURCE, + POS_TEX_VERTEX_SHADER_SOURCE, + POS_VERTEX_SHADER_SOURCE, +} from "./shader_sources"; +import { LogManager } from "../Logger"; + +const logger = LogManager.get("core/rendering/WebglRenderer"); + +export class WebglRenderer extends Renderer { + private readonly gl: GL; + private readonly shader_programs: ShaderProgram[]; + private render_scheduled = false; + private projection!: Matrix4; + + protected readonly scene: Scene; + + readonly canvas_element: HTMLCanvasElement; + + constructor() { + super(); + + this.canvas_element = document.createElement("canvas"); + + const gl = this.canvas_element.getContext("webgl2"); + + if (gl == null) { + throw new Error("Failed to initialize webgl2 context."); + } + + this.gl = gl; + + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.CULL_FACE); + + this.shader_programs = [ + new ShaderProgram(gl, POS_VERTEX_SHADER_SOURCE, POS_FRAG_SHADER_SOURCE), + new ShaderProgram(gl, POS_TEX_VERTEX_SHADER_SOURCE, POS_TEX_FRAG_SHADER_SOURCE), + ]; + + this.scene = new Scene(gl); + + this.set_size(800, 600); + + requestAnimationFrame(this.render); + } + + dispose(): void { + for (const program of this.shader_programs) { + program.delete(); + } + + this.scene.delete(); + } + + start_rendering(): void { + // TODO + } + + stop_rendering(): void { + // TODO + } + + schedule_render = (): void => { + this.render_scheduled = true; + }; + + set_size(width: number, height: number): void { + this.canvas_element.width = width; + this.canvas_element.height = height; + this.gl.viewport(0, 0, width, height); + + // prettier-ignore + this.projection = Matrix4.of( + 2/width, 0, 0, 0, + 0, 2/height, 0, 0, + 0, 0, 2/10, 0, + 0, 0, 0, 1, + ); + } + + private render = (): void => { + const gl = this.gl; + + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + this.scene.traverse((node, parent_transform) => { + const transform = matrix4_product(parent_transform, node.transform.matrix4); + + if (node.mesh) { + const program = this.shader_programs[node.mesh.format]; + program.bind(); + + program.set_transform(transform); + + if (node.mesh.texture) { + gl.activeTexture(gl.TEXTURE0); + node.mesh.texture.bind(gl); + program.set_texture(node.mesh.texture); + } + + node.mesh.render(gl); + + node.mesh.texture?.unbind(gl); + program.unbind(); + } + + return transform; + }, this.projection); + + requestAnimationFrame(this.render); + }; +} diff --git a/src/core/rendering/shader_sources.ts b/src/core/rendering/shader_sources.ts new file mode 100644 index 00000000..9eb26bc8 --- /dev/null +++ b/src/core/rendering/shader_sources.ts @@ -0,0 +1,55 @@ +export const POS_VERTEX_SHADER_SOURCE = `#version 300 es + +precision mediump float; + +uniform mat4 transform; + +in vec4 pos; + +void main() { + gl_Position = transform * pos; +} +`; + +export const POS_FRAG_SHADER_SOURCE = `#version 300 es + +precision mediump float; + +out vec4 frag_color; + +void main() { + frag_color = vec4(0, 1, 1, 1); +} +`; + +export const POS_TEX_VERTEX_SHADER_SOURCE = `#version 300 es + +precision mediump float; + +uniform mat4 transform; + +in vec4 pos; +in vec2 tex; + +out vec2 f_tex; + +void main() { + gl_Position = transform * pos; + f_tex = tex; +} +`; + +export const POS_TEX_FRAG_SHADER_SOURCE = `#version 300 es + +precision mediump float; + +uniform sampler2D tex_sampler; + +in vec2 f_tex; + +out vec4 frag_color; + +void main() { + frag_color = texture(tex_sampler, f_tex); +} +`; diff --git a/src/index.ts b/src/index.ts index 625bbf1d..47c6ca12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import "@fortawesome/fontawesome-free/js/brands"; import { initialize_application } from "./application"; import { FetchClient } from "./core/HttpClient"; import { WebGLRenderer } from "three"; -import { DisposableThreeRenderer } from "./core/rendering/Renderer"; +import { DisposableThreeRenderer } from "./core/rendering/ThreeRenderer"; import { Random } from "./core/Random"; import { DateClock } from "./core/Clock"; diff --git a/src/quest_editor/gui/QuestEditorRendererView.ts b/src/quest_editor/gui/QuestEditorRendererView.ts index a2d91163..b7eb9bc7 100644 --- a/src/quest_editor/gui/QuestEditorRendererView.ts +++ b/src/quest_editor/gui/QuestEditorRendererView.ts @@ -5,7 +5,7 @@ import { QuestRendererView } from "./QuestRendererView"; import { QuestEntityControls } from "../rendering/QuestEntityControls"; import { AreaAssetLoader } from "../loading/AreaAssetLoader"; import { EntityAssetLoader } from "../loading/EntityAssetLoader"; -import { DisposableThreeRenderer } from "../../core/rendering/Renderer"; +import { DisposableThreeRenderer } from "../../core/rendering/ThreeRenderer"; export class QuestEditorRendererView extends QuestRendererView { private readonly entity_controls: QuestEntityControls; diff --git a/src/quest_editor/gui/QuestRunnerRendererView.ts b/src/quest_editor/gui/QuestRunnerRendererView.ts index b85d55c6..ecf50d51 100644 --- a/src/quest_editor/gui/QuestRunnerRendererView.ts +++ b/src/quest_editor/gui/QuestRunnerRendererView.ts @@ -4,7 +4,7 @@ import { QuestRendererView } from "./QuestRendererView"; import { QuestEditorStore } from "../stores/QuestEditorStore"; import { AreaAssetLoader } from "../loading/AreaAssetLoader"; import { EntityAssetLoader } from "../loading/EntityAssetLoader"; -import { DisposableThreeRenderer } from "../../core/rendering/Renderer"; +import { DisposableThreeRenderer } from "../../core/rendering/ThreeRenderer"; export class QuestRunnerRendererView extends QuestRendererView { constructor( diff --git a/src/quest_editor/index.ts b/src/quest_editor/index.ts index 4b556e09..a8752713 100644 --- a/src/quest_editor/index.ts +++ b/src/quest_editor/index.ts @@ -7,7 +7,7 @@ import { AreaAssetLoader } from "./loading/AreaAssetLoader"; import { HttpClient } from "../core/HttpClient"; import { EntityImageRenderer } from "./rendering/EntityImageRenderer"; import { EntityAssetLoader } from "./loading/EntityAssetLoader"; -import { DisposableThreeRenderer } from "../core/rendering/Renderer"; +import { DisposableThreeRenderer } from "../core/rendering/ThreeRenderer"; import { QuestEditorUiPersister } from "./persistence/QuestEditorUiPersister"; import { QuestEditorToolBarView } from "./gui/QuestEditorToolBarView"; import { QuestEditorToolBarController } from "./controllers/QuestEditorToolBarController"; diff --git a/src/quest_editor/rendering/EntityImageRenderer.ts b/src/quest_editor/rendering/EntityImageRenderer.ts index fdda5685..c5be7453 100644 --- a/src/quest_editor/rendering/EntityImageRenderer.ts +++ b/src/quest_editor/rendering/EntityImageRenderer.ts @@ -4,7 +4,7 @@ import { create_entity_type_mesh } from "./conversion/entities"; import { sequential } from "../../core/sequential"; import { EntityAssetLoader } from "../loading/EntityAssetLoader"; import { Disposable } from "../../core/observable/Disposable"; -import { DisposableThreeRenderer } from "../../core/rendering/Renderer"; +import { DisposableThreeRenderer } from "../../core/rendering/ThreeRenderer"; import { LoadingCache } from "../loading/LoadingCache"; import { DisposablePromise } from "../../core/DisposablePromise"; diff --git a/src/quest_editor/rendering/QuestRenderer.ts b/src/quest_editor/rendering/QuestRenderer.ts index e18c3047..47264de4 100644 --- a/src/quest_editor/rendering/QuestRenderer.ts +++ b/src/quest_editor/rendering/QuestRenderer.ts @@ -1,4 +1,4 @@ -import { DisposableThreeRenderer, Renderer } from "../../core/rendering/Renderer"; +import { DisposableThreeRenderer, ThreeRenderer } from "../../core/rendering/ThreeRenderer"; import { Group, Mesh, MeshLambertMaterial, Object3D, PerspectiveCamera } from "three"; import { QuestEntityModel } from "../model/QuestEntityModel"; import { Quest3DModelManager } from "./Quest3DModelManager"; @@ -6,7 +6,7 @@ import { Disposer } from "../../core/observable/Disposer"; import { ColorType, EntityUserData, NPC_COLORS, OBJECT_COLORS } from "./conversion/entities"; import { QuestNpcModel } from "../model/QuestNpcModel"; -export class QuestRenderer extends Renderer { +export class QuestRenderer extends ThreeRenderer { private _collision_geometry = new Object3D(); private _render_geometry = new Object3D(); private _entity_models = new Object3D(); diff --git a/src/viewer/gui/TextureView.ts b/src/viewer/gui/TextureView.ts index 827b4597..4c6f15e1 100644 --- a/src/viewer/gui/TextureView.ts +++ b/src/viewer/gui/TextureView.ts @@ -2,10 +2,10 @@ import { div, Icon } from "../../core/gui/dom"; import { FileButton } from "../../core/gui/FileButton"; import { ToolBar } from "../../core/gui/ToolBar"; import { RendererWidget } from "../../core/gui/RendererWidget"; -import { TextureRenderer } from "../rendering/TextureRenderer"; import { ResizableView } from "../../core/gui/ResizableView"; import { TextureController } from "../controllers/TextureController"; import { ResultDialog } from "../../core/gui/ResultDialog"; +import { Renderer } from "../../core/rendering/Renderer"; export class TextureView extends ResizableView { readonly element = div({ className: "viewer_TextureView" }); @@ -20,7 +20,7 @@ export class TextureView extends ResizableView { private readonly renderer_view: RendererWidget; - constructor(ctrl: TextureController, renderer: TextureRenderer) { + constructor(ctrl: TextureController, renderer: Renderer) { super(); this.renderer_view = this.add(new RendererWidget(renderer)); diff --git a/src/viewer/index.ts b/src/viewer/index.ts index ad9b4476..5ad9a839 100644 --- a/src/viewer/index.ts +++ b/src/viewer/index.ts @@ -1,17 +1,11 @@ import { ViewerView } from "./gui/ViewerView"; import { GuiStore } from "../core/stores/GuiStore"; import { HttpClient } from "../core/HttpClient"; -import { DisposableThreeRenderer } from "../core/rendering/Renderer"; +import { DisposableThreeRenderer } from "../core/rendering/ThreeRenderer"; import { Disposable } from "../core/observable/Disposable"; import { Disposer } from "../core/observable/Disposer"; -import { TextureRenderer } from "./rendering/TextureRenderer"; -import { ModelRenderer } from "./rendering/ModelRenderer"; import { Random } from "../core/Random"; -import { ModelToolBarView } from "./gui/model/ModelToolBarView"; -import { ModelStore } from "./stores/ModelStore"; -import { ModelToolBarController } from "./controllers/model/ModelToolBarController"; -import { CharacterClassOptionsView } from "./gui/model/CharacterClassOptionsView"; -import { CharacterClassOptionsController } from "./controllers/model/CharacterClassOptionsController"; +import { Renderer } from "../core/rendering/Renderer"; export function initialize_viewer( http_client: HttpClient, @@ -26,10 +20,23 @@ export function initialize_viewer( async () => { const { ModelController } = await import("./controllers/model/ModelController"); + const { ModelRenderer } = await import("./rendering/ModelRenderer"); const { ModelView } = await import("./gui/model/ModelView"); const { CharacterClassAssetLoader } = await import( "./loading/CharacterClassAssetLoader" ); + const { ModelToolBarView } = await import("./gui/model/ModelToolBarView"); + const { ModelStore } = await import("./stores/ModelStore"); + const { ModelToolBarController } = await import( + "./controllers/model/ModelToolBarController" + ); + const { CharacterClassOptionsView } = await import( + "./gui/model/CharacterClassOptionsView" + ); + const { CharacterClassOptionsController } = await import( + "./controllers/model/CharacterClassOptionsController" + ); + const asset_loader = disposer.add(new CharacterClassAssetLoader(http_client)); const store = disposer.add(new ModelStore(gui_store, asset_loader, random)); const model_controller = new ModelController(store); @@ -47,12 +54,20 @@ export function initialize_viewer( async () => { const { TextureController } = await import("./controllers/TextureController"); const { TextureView } = await import("./gui/TextureView"); + const controller = disposer.add(new TextureController()); - return new TextureView( - controller, - new TextureRenderer(controller, create_three_renderer()), - ); + let renderer: Renderer; + + if (gui_store.feature_active("renderer")) { + const { WebglTextureRenderer } = await import("./rendering/WebglTextureRenderer"); + renderer = new WebglTextureRenderer(controller); + } else { + const { TextureRenderer } = await import("./rendering/TextureRenderer"); + renderer = new TextureRenderer(controller, create_three_renderer()); + } + + return new TextureView(controller, renderer); }, ); diff --git a/src/viewer/rendering/ModelRenderer.ts b/src/viewer/rendering/ModelRenderer.ts index 932e4515..60aefa63 100644 --- a/src/viewer/rendering/ModelRenderer.ts +++ b/src/viewer/rendering/ModelRenderer.ts @@ -19,7 +19,7 @@ import { create_animation_clip, PSO_FRAME_RATE, } from "../../core/rendering/conversion/ninja_animation"; -import { DisposableThreeRenderer, Renderer } from "../../core/rendering/Renderer"; +import { DisposableThreeRenderer, ThreeRenderer } from "../../core/rendering/ThreeRenderer"; import { Disposer } from "../../core/observable/Disposer"; import { ChangeEvent } from "../../core/observable/Observable"; import { LogManager } from "../../core/Logger"; @@ -40,7 +40,7 @@ const DEFAULT_SKINNED_MATERIAL = new MeshLambertMaterial({ const CAMERA_POSITION = Object.freeze(new Vector3(0, 10, 20)); const CAMERA_LOOK_AT = Object.freeze(new Vector3(0, 0, 0)); -export class ModelRenderer extends Renderer implements Disposable { +export class ModelRenderer extends ThreeRenderer implements Disposable { private readonly disposer = new Disposer(); private readonly clock = new Clock(); private character_class_active: boolean; diff --git a/src/viewer/rendering/TextureRenderer.ts b/src/viewer/rendering/TextureRenderer.ts index 4279ccc1..30c0cba5 100644 --- a/src/viewer/rendering/TextureRenderer.ts +++ b/src/viewer/rendering/TextureRenderer.ts @@ -8,7 +8,7 @@ import { Vector3, } from "three"; import { Disposable } from "../../core/observable/Disposable"; -import { DisposableThreeRenderer, Renderer } from "../../core/rendering/Renderer"; +import { DisposableThreeRenderer, ThreeRenderer } from "../../core/rendering/ThreeRenderer"; import { Disposer } from "../../core/observable/Disposer"; import { XvrTexture } from "../../core/data_formats/parsing/ninja/texture"; import { xvr_texture_to_texture } from "../../core/rendering/conversion/ninja_textures"; @@ -17,7 +17,7 @@ import { TextureController } from "../controllers/TextureController"; const logger = LogManager.get("viewer/rendering/TextureRenderer"); -export class TextureRenderer extends Renderer implements Disposable { +export class TextureRenderer extends ThreeRenderer implements Disposable { private readonly disposer = new Disposer(); private readonly quad_meshes: Mesh[] = []; diff --git a/src/viewer/rendering/WebglTextureRenderer.ts b/src/viewer/rendering/WebglTextureRenderer.ts new file mode 100644 index 00000000..eaedbbce --- /dev/null +++ b/src/viewer/rendering/WebglTextureRenderer.ts @@ -0,0 +1,98 @@ +import { Disposer } from "../../core/observable/Disposer"; +import { LogManager } from "../../core/Logger"; +import { TextureController } from "../controllers/TextureController"; +import { WebglRenderer } from "../../core/rendering/WebglRenderer"; +import { XvrTexture } from "../../core/data_formats/parsing/ninja/texture"; +import { Mesh, MeshBuilder } from "../../core/rendering/Mesh"; +import { TranslateTransform } from "../../core/rendering/Transform"; +import { VertexFormat } from "../../core/rendering/VertexFormat"; +import { Texture, TextureFormat } from "../../core/rendering/Texture"; + +const logger = LogManager.get("viewer/rendering/WebglTextureRenderer"); + +export class WebglTextureRenderer extends WebglRenderer { + private readonly disposer = new Disposer(); + + constructor(ctrl: TextureController) { + super(); + + this.disposer.add_all( + ctrl.textures.observe(({ value: textures }) => { + this.render_textures(textures); + this.schedule_render(); + }), + ); + } + + dispose(): void { + super.dispose(); + this.disposer.dispose(); + } + + private render_textures(textures: readonly XvrTexture[]): void { + this.scene.delete(); + + let total_width = 10 * (textures.length - 1); // 10px spacing between textures. + let total_height = 0; + + for (const tex of textures) { + total_width += tex.width; + total_height = Math.max(total_height, tex.height); + } + + let x = -Math.floor(total_width / 2); + const y = -Math.floor(total_height / 2); + + for (const tex of textures) { + try { + const quad_mesh = this.create_quad(tex); + + this.scene.add_child( + this.scene.root_node, + quad_mesh, + new TranslateTransform(x, y + (total_height - tex.height) / 2, 0), + ); + } catch (e) { + logger.error("Couldn't create quad for texture.", e); + } + + x += 10 + tex.width; + } + } + + private create_quad(tex: XvrTexture): Mesh { + return new MeshBuilder(VertexFormat.PosTex) + .vertex(0, 0, 0, 0, 1) + .vertex(tex.width, 0, 0, 1, 1) + .vertex(tex.width, tex.height, 0, 1, 0) + .vertex(0, tex.height, 0, 0, 0) + + .triangle(0, 1, 2) + .triangle(2, 3, 0) + + .texture(xvr_texture_to_texture(tex)) + + .build(); + } +} + +export function xvr_texture_to_texture(tex: XvrTexture): Texture { + let format: TextureFormat; + let data_size: number; + + // Ignore mipmaps. + switch (tex.format[1]) { + case 6: + format = TextureFormat.RGBA_S3TC_DXT1; + data_size = (tex.width * tex.height) / 2; + break; + case 7: + format = TextureFormat.RGBA_S3TC_DXT3; + data_size = tex.width * tex.height; + break; + default: + throw new Error(`Format ${tex.format.join(", ")} not supported.`); + } + + return new Texture(tex.width, tex.height, format, tex.data.slice(0, data_size)); +} diff --git a/test/src/core/rendering/StubThreeRenderer.ts b/test/src/core/rendering/StubThreeRenderer.ts index 7806c5b5..1c8c8596 100644 --- a/test/src/core/rendering/StubThreeRenderer.ts +++ b/test/src/core/rendering/StubThreeRenderer.ts @@ -1,4 +1,4 @@ -import { DisposableThreeRenderer } from "../../../../src/core/rendering/Renderer"; +import { DisposableThreeRenderer } from "../../../../src/core/rendering/ThreeRenderer"; export class StubThreeRenderer implements DisposableThreeRenderer { domElement: HTMLCanvasElement = document.createElement("canvas");