Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.local
174 changes: 113 additions & 61 deletions README.md

Large diffs are not rendered by default.

Binary file added docs/screenshots/chess.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/denoiser.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/dragon.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/helmet.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/hero.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/screenshots/hero.png
Binary file not shown.
Binary file added docs/screenshots/sheen-chair.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/toycar.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/thumbs/cornell.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/thumbs/material.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/thumbs/nocturne.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/thumbs/showroom.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
91 changes: 91 additions & 0 deletions src/gallery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Remote content for the gallery: Khronos glTF sample models and Poly Haven
* HDRIs, fetched on demand (both hosts send CORS headers). Nothing here is
* bundled, so the app stays small and works offline with the built-in scenes.
*/

export interface HDRIEntry {
id: string;
name: string;
}

/** CC0 HDRIs from polyhaven.com. */
export const HDRIS: readonly HDRIEntry[] = [
{ id: 'studio_small_08', name: 'Studio' },
{ id: 'brown_photostudio_02', name: 'Photo studio' },
{ id: 'venice_sunset', name: 'Venice sunset' },
{ id: 'royal_esplanade', name: 'Royal esplanade' },
{ id: 'kiara_1_dawn', name: 'Kiara dawn' },
{ id: 'sunflowers_puresky', name: 'Sunflowers sky' },
{ id: 'aerodynamics_workshop', name: 'Workshop' },
{ id: 'lebombo', name: 'Lebombo interior' },
];

export const hdriURL = (id: string): string => `https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/2k/${id}_2k.hdr`;

export interface GalleryModel {
id: string;
name: string;
/** What the model exercises. */
features: string;
credit: string;
/** Download size of the .glb, for the card. */
megabytes: number;
hdri: string;
rotation?: number;
exposure?: number;
azimuth?: number;
elevation?: number;
distance?: number;
groundColor?: readonly [number, number, number];
groundRoughness?: number;
thumbnail: string;
}

const KHRONOS = 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models';

export const modelURL = (id: string): string => `${KHRONOS}/${id}/glTF-Binary/${id}.glb`;
const shot = (id: string, file = 'screenshot.jpg') => `${KHRONOS}/${id}/screenshot/${file}`;

export const MODELS: readonly GalleryModel[] = [
{
id: 'ToyCar', name: 'Toy Car', features: 'Clearcoat · sheen · transmission',
credit: 'Guido Odendahl, Eric Chadwick · CC0', megabytes: 5.4, hdri: 'studio_small_08', thumbnail: shot('ToyCar'),
azimuth: 24, elevation: 12, distance: 0.8,
},
{
id: 'DragonAttenuation', name: 'Dragon', features: 'Volumetric absorption · refraction',
credit: 'Stanford CG Lab, Morgan McGuire · glTF CC0', megabytes: 6.4, hdri: 'venice_sunset', thumbnail: shot('DragonAttenuation'),
azimuth: 10, elevation: 8, distance: 0.7,
},
{
id: 'SheenChair', name: 'Sheen Chair', features: 'Fabric sheen · texture transforms',
credit: 'Wayfair · CC0', megabytes: 4.1, hdri: 'brown_photostudio_02', thumbnail: shot('SheenChair'),
},
{
id: 'DamagedHelmet', name: 'Damaged Helmet', features: 'PBR maps · normal mapping · emissive',
credit: 'theblueturtle_ (CC BY-NC), ctxwing (CC BY)', megabytes: 3.8, hdri: 'royal_esplanade', thumbnail: shot('DamagedHelmet', 'screenshot.png'),
},
{
id: 'CarConcept', name: 'Car Concept', features: 'Clearcoat car paint · glass',
credit: 'Darmstadt Graphics Group · CC BY', megabytes: 11.8, hdri: 'kiara_1_dawn', thumbnail: shot('CarConcept'),
azimuth: 38, elevation: 6, distance: 0.95, groundColor: [0.05, 0.05, 0.055], groundRoughness: 0.35,
},
{
id: 'ChronographWatch', name: 'Chronograph Watch', features: 'Transmission · fine detail',
credit: 'Darmstadt Graphics Group · CC BY', megabytes: 7.4, hdri: 'studio_small_08', thumbnail: shot('ChronographWatch'),
},
{
id: 'AntiqueCamera', name: 'Antique Camera', features: 'Metal-rough textures',
credit: 'UX3D · CC0', megabytes: 17.5, hdri: 'aerodynamics_workshop', thumbnail: shot('AntiqueCamera'),
},
{
id: 'GlamVelvetSofa', name: 'Velvet Sofa', features: 'Sheen velvet',
credit: 'Wayfair · CC BY', megabytes: 3.1, hdri: 'lebombo', thumbnail: shot('GlamVelvetSofa'),
},
{
id: 'ABeautifulGame', name: 'A Beautiful Game', features: '1.5M triangles · glass pieces',
credit: 'ASWF, Ed Mackey · CC BY', megabytes: 43, hdri: 'brown_photostudio_02', thumbnail: shot('ABeautifulGame'),
azimuth: 20, elevation: 28, distance: 0.75, groundColor: [0.08, 0.07, 0.065], groundRoughness: 0.5,
},
];
142 changes: 123 additions & 19 deletions src/gl/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export class GLError extends Error {}
export interface GLCapabilities {
gl: WebGL2RenderingContext;
maxTextureSize: number;
maxArrayLayers: number;
/** Bilinear filtering of RGB(A)32F textures (OES_texture_float_linear). */
floatLinear: boolean;
renderer: string;
}

Expand All @@ -27,7 +30,13 @@ export function createContext(canvas: HTMLCanvasElement): GLCapabilities {
}
const dbg = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = dbg ? String(gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)) : 'unknown GPU';
return { gl, maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number, renderer };
return {
gl,
maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,
maxArrayLayers: gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS) as number,
floatLinear: gl.getExtension('OES_texture_float_linear') !== null,
renderer,
};
}

function compileShader(gl: WebGL2RenderingContext, type: number, source: string, sources: ShaderSources): WebGLShader {
Expand Down Expand Up @@ -90,6 +99,7 @@ export class Program {
i1(name: string, v: number) { this.gl.uniform1i(this.loc(name), v); }
u1(name: string, v: number) { this.gl.uniform1ui(this.loc(name), v); }
f2(name: string, x: number, y: number) { this.gl.uniform2f(this.loc(name), x, y); }
i2(name: string, x: number, y: number) { this.gl.uniform2i(this.loc(name), x, y); }
f3(name: string, v: ArrayLike<number>) { this.gl.uniform3f(this.loc(name), v[0]!, v[1]!, v[2]!); }
f4v(name: string, v: Float32Array) { this.gl.uniform4fv(this.loc(name), v); }
i1v(name: string, v: Int32Array) { this.gl.uniform1iv(this.loc(name), v); }
Expand Down Expand Up @@ -120,51 +130,145 @@ export function createDataTexture(gl: WebGL2RenderingContext, data: Float32Array
return tex;
}

/** A float render target. */
export interface TextureOptions {
internalFormat: number;
format: number;
type: number;
filter?: number;
wrapS?: number;
wrapT?: number;
}

/** Allocates a 2D texture, optionally filling it. */
export function createTexture(
gl: WebGL2RenderingContext,
width: number,
height: number,
opts: TextureOptions,
data: ArrayBufferView | null = null,
): WebGLTexture {
const tex = gl.createTexture();
if (!tex) throw new GLError('Failed to allocate texture');
gl.bindTexture(gl.TEXTURE_2D, tex);
const filter = opts.filter ?? gl.NEAREST;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, opts.wrapS ?? gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, opts.wrapT ?? gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, opts.internalFormat, width, height, 0, opts.format, opts.type, data);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
gl.bindTexture(gl.TEXTURE_2D, null);
return tex;
}

/** A float render target with one or more colour attachments (MRT). */
export class RenderTarget {
readonly texture: WebGLTexture;
readonly textures: WebGLTexture[];
readonly framebuffer: WebGLFramebuffer;

constructor(
private readonly gl: WebGL2RenderingContext,
readonly width: number,
readonly height: number,
formats: number[] = [gl.RGBA32F],
) {
const tex = gl.createTexture();
const fb = gl.createFramebuffer();
if (!tex || !fb) throw new GLError('Failed to allocate render target');
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
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.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, null);
if (!fb) throw new GLError('Failed to allocate render target');
this.textures = formats.map((f) =>
createTexture(gl, width, height, {
internalFormat: f,
format: f === gl.R32F ? gl.RED : gl.RGBA,
type: f === gl.RGBA16F ? gl.HALF_FLOAT : gl.FLOAT,
}),
);
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
this.textures.forEach((tex, i) => gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0));
gl.drawBuffers(this.textures.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
throw new GLError(`Float framebuffer incomplete (status 0x${status.toString(16)})`);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// Never leave the attachment bound on a sampler unit: that is a feedback loop waiting to happen.
gl.bindTexture(gl.TEXTURE_2D, null);
this.texture = tex;
this.framebuffer = fb;
}

bind(): void {
get texture(): WebGLTexture {
return this.textures[0]!;
}

/** Binds for drawing into the bottom-left `width × height` region (the whole target by default). */
bind(width = this.width, height = this.height): void {
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.framebuffer);
this.gl.viewport(0, 0, this.width, this.height);
this.gl.viewport(0, 0, width, height);
}

clear(): void {
this.bind();
this.gl.clearColor(0, 0, 0, 1);
this.gl.clearColor(0, 0, 0, 0);
this.gl.clear(this.gl.COLOR_BUFFER_BIT);
}

dispose(): void {
this.gl.deleteTexture(this.texture);
for (const t of this.textures) this.gl.deleteTexture(t);
this.gl.deleteFramebuffer(this.framebuffer);
}
}

/**
* Builds a `size × size × layers` texture array by drawing each image into its
* layer through `resample` (a fullscreen program sampling unit 0). Images are
* uploaded mipmapped so downscaling filters properly. sRGB arrays decode on
* fetch and re-encode on write, so filtering happens in linear space.
*/
export function createTextureArray(
gl: WebGL2RenderingContext,
images: readonly TexImageSource[],
size: number,
srgb: boolean,
resample: Program,
vao: WebGLVertexArrayObject,
): WebGLTexture {
const internal = srgb ? gl.SRGB8_ALPHA8 : gl.RGBA8;
const layers = Math.max(1, images.length);
const array = gl.createTexture();
const fb = gl.createFramebuffer();
if (!array || !fb) throw new GLError('Failed to allocate texture array');
gl.bindTexture(gl.TEXTURE_2D_ARRAY, array);
gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, internal, size, size, layers);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, null);

resample.use();
resample.i1('uSource', 0);
resample.f1('uSize', size);
gl.bindVertexArray(vao);
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.drawBuffers([gl.COLOR_ATTACHMENT0]);
gl.viewport(0, 0, size, size);
gl.activeTexture(gl.TEXTURE0);
images.forEach((img, layer) => {
const src = gl.createTexture();
if (!src) throw new GLError('Failed to allocate texture');
gl.bindTexture(gl.TEXTURE_2D, src);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
gl.texImage2D(gl.TEXTURE_2D, 0, internal, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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.framebufferTextureLayer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, array, 0, layer);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindTexture(gl.TEXTURE_2D, null);
gl.deleteTexture(src);
});
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.deleteFramebuffer(fb);
return array;
}
Loading
Loading