diff --git a/examples/src/examples/gizmos/bounding-box.example.mjs b/examples/src/examples/gizmos/bounding-box.example.mjs index a33371b6fe2..1345d4d6af3 100644 --- a/examples/src/examples/gizmos/bounding-box.example.mjs +++ b/examples/src/examples/gizmos/bounding-box.example.mjs @@ -130,7 +130,6 @@ data.set('gizmo', { // Create grid const gridEntity = new Entity('grid'); -gridEntity.setLocalScale(4, 1, 4); app.root.addChild(gridEntity); gridEntity.addComponent('script'); gridEntity.script.create(Grid); diff --git a/examples/src/examples/gizmos/transform-rotate.example.mjs b/examples/src/examples/gizmos/transform-rotate.example.mjs index 0ce05c66261..c5f4288381c 100644 --- a/examples/src/examples/gizmos/transform-rotate.example.mjs +++ b/examples/src/examples/gizmos/transform-rotate.example.mjs @@ -162,7 +162,6 @@ data.set('gizmo', { // Create grid const gridEntity = new Entity('grid'); -gridEntity.setLocalScale(4, 1, 4); app.root.addChild(gridEntity); gridEntity.addComponent('script'); gridEntity.script.create(Grid); diff --git a/examples/src/examples/gizmos/transform-scale.example.mjs b/examples/src/examples/gizmos/transform-scale.example.mjs index 0d6d9f36e99..735c73b5040 100644 --- a/examples/src/examples/gizmos/transform-scale.example.mjs +++ b/examples/src/examples/gizmos/transform-scale.example.mjs @@ -164,7 +164,6 @@ data.set('gizmo', { // Create grid const gridEntity = new Entity('grid'); -gridEntity.setLocalScale(4, 1, 4); app.root.addChild(gridEntity); gridEntity.addComponent('script'); gridEntity.script.create(Grid); diff --git a/examples/src/examples/gizmos/transform-translate.example.mjs b/examples/src/examples/gizmos/transform-translate.example.mjs index d51096365cc..bc256c53f9e 100644 --- a/examples/src/examples/gizmos/transform-translate.example.mjs +++ b/examples/src/examples/gizmos/transform-translate.example.mjs @@ -164,7 +164,6 @@ data.set('gizmo', { // Create grid const gridEntity = new Entity('grid'); -gridEntity.setLocalScale(4, 1, 4); app.root.addChild(gridEntity); gridEntity.addComponent('script'); gridEntity.script.create(Grid); diff --git a/examples/src/examples/graphics/grid.controls.jsx b/examples/src/examples/graphics/grid.controls.jsx new file mode 100644 index 00000000000..409dbe47b0b --- /dev/null +++ b/examples/src/examples/graphics/grid.controls.jsx @@ -0,0 +1,163 @@ +import { + BindingTwoWay, + BooleanInput, + ColorPicker, + LabelGroup, + Panel, + SelectInput, + SliderInput, + VectorInput +} from '@playcanvas/pcui/react'; +import { useEffect, useState } from 'react'; + +import { PROJECTION_ORTHOGRAPHIC, PROJECTION_PERSPECTIVE } from 'playcanvas'; + +/** + * @import { Observer } from '@playcanvas/observer' + * @import { ReactElement } from 'react' + */ + +/** + * @param {{ observer: Observer }} props - The control panel props. + * @returns {ReactElement} The control panel. + */ +export function Controls({ observer }) { + // size only when not infinite + const [infinite, setInfinite] = useState(observer.get('grid.infinite') ?? true); + useEffect(() => { + const event = observer.on('grid.infinite:set', setInfinite); + return () => event?.unbind(); + }, [observer]); + + // axis thickness only when axes show + const [showAxes, setShowAxes] = useState(observer.get('grid.showAxes') ?? true); + useEffect(() => { + const event = observer.on('grid.showAxes:set', setShowAxes); + return () => event?.unbind(); + }, [observer]); + + /** + * @param {string} text - The label. + * @param {string} path - The observer path. + * @param {number} min - The minimum. + * @param {number} max - The maximum. + * @param {number} [precision] - The precision. + * @returns {ReactElement} The slider. + */ + const slider = (text, path, min, max, precision = 2) => ( + + + + ); + + /** + * @param {string} text - The label. + * @param {string} path - The observer path. + * @returns {ReactElement} The toggle. + */ + const toggle = (text, path) => ( + + + + ); + + /** + * @param {string} text - The label. + * @param {string} path - The observer path. + * @returns {ReactElement} The color picker. + */ + const color = (text, path) => ( + + + + ); + + return ( + <> + + + + + {toggle('Enabled', 'grid.enabled')} + + + + {slider('Spacing', 'grid.spacing', 0.1, 50, 1)} + {slider('Subdivisions', 'grid.subdivisions', 1, 10, 0)} + + + {toggle('Infinite', 'grid.infinite')} + {!infinite && ( + + + + )} + {slider('Fade Start', 'grid.fadeStart', 0, 1000, 0)} + {slider('Fade End', 'grid.fadeEnd', 0, 1000, 0)} + + + {color('Color X', 'grid.colorX')} + {color('Color Y', 'grid.colorY')} + {color('Color Z', 'grid.colorZ')} + {color('Major Line Color', 'grid.majorLineColor')} + {color('Minor Line Color', 'grid.minorLineColor')} + + + {toggle('Show Axes', 'grid.showAxes')} + {showAxes && slider('Axis Line Thickness', 'grid.axisLineThickness', 0.5, 5, 1)} + + + {slider('Major Line Thickness', 'grid.majorLineThickness', 0.5, 5, 1)} + {slider('Minor Line Thickness', 'grid.minorLineThickness', 0.5, 5, 1)} + {toggle('Contrast', 'grid.contrast')} + + + {slider('Depth Offset', 'grid.depthOffset', 0, 0.01, 4)} + {toggle('Depth Test', 'grid.depthTest')} + {toggle('Depth Write', 'grid.depthWrite')} + {toggle('Depth Dither', 'grid.depthDither')} + + + + + + {slider('Far Clip', 'camera.farClip', 10, 2000, 0)} + + + ); +} diff --git a/examples/src/examples/graphics/grid.example.mjs b/examples/src/examples/graphics/grid.example.mjs new file mode 100644 index 00000000000..e0382b22d9d --- /dev/null +++ b/examples/src/examples/graphics/grid.example.mjs @@ -0,0 +1,376 @@ +// @config +// +// A reference grid with every setting of the Grid script. Lower Depth Offset to see the dark plane +// hide it, and pick the SuperSplat preset to see it hide gaussian splats with Depth Write. +// +// `LMB` Orbit · `RMB` Pan · `Wheel` / `Pinch` Zoom + +import { + AppBase, + AppOptions, + BoundingBox, + CameraComponentSystem, + Color, + Entity, + FILLMODE_FILL_WINDOW, + FloatPacking, + GSplatComponentSystem, + GSplatContainer, + GSplatFormat, + Keyboard, + LightComponentSystem, + Mouse, + PROJECTION_PERSPECTIVE, + RESOLUTION_AUTO, + RenderComponentSystem, + ScriptComponentSystem, + StandardMaterial, + Vec2, + Vec3, + createGraphicsDevice +} from 'playcanvas'; +import { CameraControls } from 'playcanvas/scripts/esm/camera-controls.mjs'; +import { Grid } from 'playcanvas/scripts/esm/grid.mjs'; + +import { data, deviceType } from 'examples/context'; + +/** + * @import { Material } from 'playcanvas' + */ + +const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas')); +window.focus(); + +const gfxOptions = { + deviceTypes: [deviceType], + + // splats are expensive multisampled, and the grid antialiases itself + antialias: false +}; + +const device = await createGraphicsDevice(canvas, gfxOptions); +device.maxPixelRatio = Math.min(window.devicePixelRatio, 2); + +const createOptions = new AppOptions(); +createOptions.graphicsDevice = device; +createOptions.mouse = new Mouse(document.body); +createOptions.keyboard = new Keyboard(window); + +createOptions.componentSystems = [ + RenderComponentSystem, + CameraComponentSystem, + LightComponentSystem, + ScriptComponentSystem, + GSplatComponentSystem +]; + +const app = new AppBase(canvas); +app.init(createOptions); + +// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size +app.setCanvasFillMode(FILLMODE_FILL_WINDOW); +app.setCanvasResolution(RESOLUTION_AUTO); + +app.start(); + +/** + * @param {Color} color - The color. + * @returns {Material} - The standard material. + */ +const createColorMaterial = (color) => { + const material = new StandardMaterial(); + material.diffuse = color; + material.update(); + return material; +}; + +// Scene settings +app.scene.ambientLight = new Color(0.2, 0.2, 0.2); + +// Create entities, half through the grid +const box = new Entity('box'); +box.addComponent('render', { + type: 'box', + material: createColorMaterial(new Color(0.8, 1, 1)) +}); +box.setPosition(1, 0, 1); +app.root.addChild(box); + +// A splat sphere for the SuperSplat preset, colored by direction and shaded +const SPLAT_COUNT = 6000; +const SPLAT_SIZE = 0.025; +const splatFormat = GSplatFormat.createSimpleFormat(device); +const container = new GSplatContainer(device, SPLAT_COUNT, splatFormat); +const centerTex = container.getTexture('dataCenter'); +const colorTex = container.getTexture('dataColor'); +const centerData = centerTex.lock(); +const colorData = colorTex.lock(); +const lightDir = new Vec3(0.5, 0.8, 0.3).normalize(); +const n = new Vec3(); +for (let i = 0; i < SPLAT_COUNT; i++) { + // fibonacci spiral + const y = 1 - (2 * (i + 0.5)) / SPLAT_COUNT; + const r = Math.sqrt(1 - y * y); + const a = i * Math.PI * (3 - Math.sqrt(5)); + n.set(Math.cos(a) * r, y, Math.sin(a) * r); + + // color by direction, lit from one side + const shade = 0.15 + 0.35 * Math.max(n.dot(lightDir), 0); + const rgb = [0.55 + 0.45 * n.x, 0.55 + 0.45 * n.y, 0.55 + 0.45 * n.z].map((c) => c * shade); + + centerData.set([n.x * 0.5, n.y * 0.5, n.z * 0.5, SPLAT_SIZE], i * 4); + for (let c = 0; c < 3; c++) { + colorData[i * 4 + c] = FloatPacking.float2Half(rgb[c]); + } + colorData[i * 4 + 3] = FloatPacking.float2Half(1); + container.centers.set([n.x * 0.5, n.y * 0.5, n.z * 0.5], i * 3); +} +centerTex.unlock(); +colorTex.unlock(); +container.aabb = new BoundingBox(Vec3.ZERO, new Vec3(0.5 + SPLAT_SIZE, 0.5 + SPLAT_SIZE, 0.5 + SPLAT_SIZE)); +container.update(SPLAT_COUNT); + +const splatSphere = new Entity('splat-sphere'); +splatSphere.addComponent('gsplat', { + unified: true +}); +splatSphere.gsplat.resource = container; +splatSphere.setPosition(-1, 0, 1); +splatSphere.enabled = false; +app.root.addChild(splatSphere); + +const sphere = new Entity('sphere'); +sphere.addComponent('render', { + type: 'sphere', + material: createColorMaterial(new Color(1, 0.8, 1)) +}); +sphere.setPosition(-1, 0, 1); +app.root.addChild(sphere); + +// A plane on the grid, hiding it without a Depth Offset +const plane = new Entity('plane'); +plane.addComponent('render', { + type: 'plane', + material: createColorMaterial(new Color(0.35, 0.35, 0.3)) +}); +plane.setPosition(-1, 0, -1); +plane.setLocalScale(1.5, 1, 1.5); +app.root.addChild(plane); + +const capsule = new Entity('capsule'); +capsule.addComponent('render', { + type: 'capsule', + material: createColorMaterial(new Color(0.8, 0.8, 1)) +}); +capsule.setPosition(1, 0, -1); +app.root.addChild(capsule); + +// Camera +const camera = new Entity('camera'); +camera.addComponent('script'); +camera.addComponent('camera', { + clearColor: new Color(0.1, 0.1, 0.1), + farClip: 1000 +}); +const cameraOffset = 4 * camera.camera.aspectRatio; +camera.setPosition(cameraOffset, cameraOffset, cameraOffset); +app.root.addChild(camera); + +// Camera controls +const cc = /** @type {CameraControls} */ (camera.script.create(CameraControls)); +Object.assign(cc, { + focusPoint: Vec3.ZERO, + sceneSize: 5, + rotateDamping: 0.95, + moveDamping: 0.95, + zoomDamping: 0.95, + pitchRange: new Vec2(-89.999, 89.999), + zoomRange: new Vec2(0.5, 500), + enableFly: false +}); + +// Create light entity +const light = new Entity('light'); +light.addComponent('light'); +app.root.addChild(light); +light.setEulerAngles(0, 0, -60); + +// Grid, sized by its scale when not infinite +const gridEntity = new Entity('grid'); +gridEntity.setLocalScale(20, 1, 20); +gridEntity.addComponent('script'); +app.root.addChild(gridEntity); +const grid = /** @type {Grid} */ (gridEntity.script.create(Grid)); + +data.set('grid', { + enabled: grid.enabled, + plane: grid.plane, + spacing: grid.spacing, + subdivisions: grid.subdivisions, + infinite: grid.infinite, + size: [20, 20], + majorLineThickness: grid.majorLineThickness, + minorLineThickness: grid.minorLineThickness, + axisLineThickness: grid.axisLineThickness, + showAxes: grid.showAxes, + contrast: grid.contrast, + colorX: grid.colorX.toArray(), + colorY: grid.colorY.toArray(), + colorZ: grid.colorZ.toArray(), + majorLineColor: grid.majorLineColor.toArray(), + minorLineColor: grid.minorLineColor.toArray(), + fadeStart: grid.fadeRange.x, + fadeEnd: grid.fadeRange.y, + depthOffset: grid.depthOffset, + depthTest: grid.depthTest, + depthWrite: grid.depthWrite, + depthDither: grid.depthDither +}); +// Presets, applied through the panel so its controls follow +const PRESETS = { + default: { + plane: 'xz', + spacing: 10, + subdivisions: 10, + infinite: true, + size: [20, 20], + fadeStart: 400, + fadeEnd: 1000, + showAxes: true, + axisLineThickness: 2, + majorLineThickness: 1.5, + minorLineThickness: 1, + contrast: false, + colorX: [1, 0.3, 0.3, 1], + colorY: [0.3, 1, 0.3, 1], + colorZ: [0.3, 0.3, 1, 1], + majorLineColor: [0.8, 0.8, 0.8, 0.6], + minorLineColor: [0.6, 0.6, 0.6, 0.4], + depthOffset: 0.001, + depthTest: true, + depthWrite: false, + depthDither: false + }, + editor: { + plane: 'xz', + spacing: 1, + subdivisions: 1, + infinite: false, + size: [8, 8], + fadeStart: 400, + fadeEnd: 1000, + showAxes: true, + axisLineThickness: 1, + majorLineThickness: 1, + minorLineThickness: 1, + contrast: false, + colorX: [0, 0, 0, 1], + colorY: [0, 0, 0, 1], + colorZ: [0, 0, 0, 1], + majorLineColor: [136 / 255, 136 / 255, 136 / 255, 1], + minorLineColor: [136 / 255, 136 / 255, 136 / 255, 1], + depthOffset: 0, + depthTest: true, + depthWrite: false, + depthDither: false + }, + supersplat: { + plane: 'xz', + spacing: 10, + subdivisions: 10, + infinite: true, + size: [20, 20], + fadeStart: 400, + fadeEnd: 1000, + showAxes: true, + axisLineThickness: 2, + majorLineThickness: 1.5, + minorLineThickness: 1, + contrast: false, + colorX: [1, 0.2, 0.2, 1], + colorY: [0.2, 1, 0.2, 1], + colorZ: [0.2, 0.2, 1, 1], + majorLineColor: [0.9, 0.9, 0.9, 1], + minorLineColor: [0.7, 0.7, 0.7, 1], + depthOffset: 0, + depthTest: true, + depthWrite: true, + depthDither: true + } +}; +data.set('preset', 'default'); + +data.set('camera', { + proj: PROJECTION_PERSPECTIVE + 1, + farClip: camera.camera.farClip +}); + +// Controls hook +const COLORS = ['colorX', 'colorY', 'colorZ', 'majorLineColor', 'minorLineColor']; +data.on('*:set', (/** @type {string} */ path, /** @type {any} */ value) => { + const [category, key] = path.split('.'); + switch (category) { + case 'grid': { + // vector inputs set one component at a time + const v = data.get(`grid.${key}`); + if (key === 'size' || key === 'plane') { + // size is the entity's scale on the plane's axes + const [w, d] = data.get('grid.size'); + const plane = data.get('grid.plane'); + grid.plane = plane; + gridEntity.setLocalScale( + plane === 'yz' ? 1 : w, + plane === 'xz' ? 1 : plane === 'xy' ? d : w, + plane === 'xy' ? 1 : d + ); + } else if (key === 'fadeStart' || key === 'fadeEnd') { + // the sliders set the range's ends + grid.fadeRange = new Vec2(data.get('grid.fadeStart'), data.get('grid.fadeEnd')); + } else if (key === 'contrast') { + // contrast needs the scene color map + camera.camera.renderSceneColorMap = v; + grid.contrast = v; + } else if (COLORS.includes(key)) { + // @ts-ignore + grid[key] = new Color().fromArray(v); + } else { + // @ts-ignore + grid[key] = v; + } + break; + } + case 'preset': { + const preset = PRESETS[/** @type {keyof typeof PRESETS} */ (value)]; + for (const [name, v] of Object.entries(preset ?? {})) { + data.set(`grid.${name}`, v); + } + + // splats only with the SuperSplat preset + splatSphere.enabled = value === 'supersplat'; + sphere.enabled = !splatSphere.enabled; + break; + } + case 'camera': { + switch (key) { + case 'proj': { + camera.camera.projection = value - 1; + break; + } + case 'farClip': { + camera.camera.farClip = value; + break; + } + } + break; + } + } +}); + +// Ensure canvas is resized when window changes size +const resize = () => app.resizeCanvas(); +window.addEventListener('resize', resize); +resize(); + +app.on('destroy', () => { + window.removeEventListener('resize', resize); +}); diff --git a/examples/src/examples/misc/editor.controls.jsx b/examples/src/examples/misc/editor.controls.jsx index 5210b93af64..7ac6f440d39 100644 --- a/examples/src/examples/misc/editor.controls.jsx +++ b/examples/src/examples/misc/editor.controls.jsx @@ -130,17 +130,6 @@ export function Controls({ observer }) { )} - - - { // Grid const gridEntity = new Entity('grid'); -gridEntity.setLocalScale(8, 1, 8); app.root.addChild(gridEntity); gridEntity.addComponent('script'); const grid = /** @type {Grid} */ (gridEntity.script.create(Grid)); data.set('grid', { colorX: Object.values(grid.colorX), - colorZ: Object.values(grid.colorZ), - resolution: grid.resolution + 1 + colorZ: Object.values(grid.colorZ) }); // Create light entity @@ -342,9 +340,6 @@ data.on('*:set', (/** @type {string} */ path, /** @type {any} */ value) => { case 'colorZ': grid.colorZ = tmpC1.set(value[0], value[1], value[2]); break; - case 'resolution': - grid.resolution = value - 1; - break; } break; } diff --git a/examples/thumbnails/graphics_grid_large.webp b/examples/thumbnails/graphics_grid_large.webp new file mode 100644 index 00000000000..448454e9348 Binary files /dev/null and b/examples/thumbnails/graphics_grid_large.webp differ diff --git a/examples/thumbnails/graphics_grid_small.webp b/examples/thumbnails/graphics_grid_small.webp new file mode 100644 index 00000000000..4630e31eaa7 Binary files /dev/null and b/examples/thumbnails/graphics_grid_small.webp differ diff --git a/scripts/esm/grid.mjs b/scripts/esm/grid.mjs index d71cca84434..2230838b33d 100644 --- a/scripts/esm/grid.mjs +++ b/scripts/esm/grid.mjs @@ -1,251 +1,21 @@ -import { - ShaderMaterial, - SEMANTIC_POSITION, - SEMANTIC_TEXCOORD0, - BLEND_NORMAL, - CULLFACE_NONE, - PlaneGeometry, - Mesh, - MeshInstance, - Color, - Script, - Vec2 -} from 'playcanvas'; - -const tmpVa = new Vec2(); - -const EPSILON = 1e-3; - -const vertexGLSL = /* glsl */ ` - attribute vec3 vertex_position; - attribute vec2 aUv0; - - uniform mat4 matrix_model; - uniform mat4 matrix_viewProjection; - - varying vec2 uv0; - - void main(void) { - gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0); - uv0 = aUv0; - } -`; - -const fragmentGLSL = /* glsl */ ` - uniform vec2 uHalfExtents; - uniform vec3 uColorX; - uniform vec3 uColorZ; - uniform int uResolution; - - varying vec2 uv0; - - // https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8#1e7c - float pristineGrid(in vec2 uv, in vec2 ddx, in vec2 ddy, vec2 lineWidth) { - vec2 uvDeriv = vec2(length(vec2(ddx.x, ddy.x)), length(vec2(ddx.y, ddy.y))); - bvec2 invertLine = bvec2(lineWidth.x > 0.5, lineWidth.y > 0.5); - vec2 targetWidth = vec2( - invertLine.x ? 1.0 - lineWidth.x : lineWidth.x, - invertLine.y ? 1.0 - lineWidth.y : lineWidth.y - ); - vec2 drawWidth = clamp(targetWidth, uvDeriv, vec2(0.5)); - vec2 lineAA = uvDeriv * 1.5; - vec2 gridUV = abs(fract(uv) * 2.0 - 1.0); - gridUV.x = invertLine.x ? gridUV.x : 1.0 - gridUV.x; - gridUV.y = invertLine.y ? gridUV.y : 1.0 - gridUV.y; - vec2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); - - grid2 *= clamp(targetWidth / drawWidth, 0.0, 1.0); - grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, 0.0, 1.0)); - grid2.x = invertLine.x ? 1.0 - grid2.x : grid2.x; - grid2.y = invertLine.y ? 1.0 - grid2.y : grid2.y; - - return mix(grid2.x, 1.0, grid2.y); - } - - void main(void) { - vec2 uv = uv0; - - vec2 pos = (uv * 2.0 - 1.0) * uHalfExtents; - vec2 ddx = dFdx(pos); - vec2 ddy = dFdy(pos); - - float epsilon = 1.0 / 255.0; - - vec2 levelPos; - float levelSize; - float levelAlpha; - - levelPos = pos * 0.1; - levelSize = 2.0 / 1000.0; - levelAlpha = pristineGrid(levelPos, ddx * 0.1, ddy * 0.1, vec2(levelSize)); - if (levelAlpha > epsilon) { - vec3 color; - if (abs(levelPos.x) < levelSize) { - if (abs(levelPos.y) < levelSize) { - color = vec3(1.0); - } else { - color = uColorZ; - } - } else if (abs(levelPos.y) < levelSize) { - color = uColorX; - } else { - color = vec3(0.9); - } - gl_FragColor = vec4(color, levelAlpha); - return; - } - - levelPos = pos; - levelSize = 1.0 / 100.0; - levelAlpha = pristineGrid(levelPos, ddx, ddy, vec2(levelSize)); - if (levelAlpha > epsilon) { - if (uResolution < 1) { - discard; - } - gl_FragColor = vec4(vec3(0.7), levelAlpha); - return; - } - - levelPos = pos * 10.0; - levelSize = 1.0 / 100.0; - levelAlpha = pristineGrid(levelPos, ddx * 10.0, ddy * 10.0, vec2(levelSize)); - if (levelAlpha > epsilon) { - if (uResolution < 2) { - discard; - } - gl_FragColor = vec4(vec3(0.7), levelAlpha); - return; - } - - discard; - } -`; - -const vertexWGSL = /* wgsl */ ` - attribute vertex_position: vec3f; - attribute aUv0: vec2f; - - uniform matrix_model: mat4x4f; - uniform matrix_viewProjection: mat4x4f; - - varying uv0: vec2f; - - @vertex - fn vertexMain(input: VertexInput) -> VertexOutput { - var output: VertexOutput; - output.position = uniform.matrix_viewProjection * uniform.matrix_model * vec4f(input.vertex_position, 1.0); - output.uv0 = input.aUv0; - return output; - } -`; - -const fragmentWGSL = /* wgsl */ ` - uniform uHalfExtents: vec2f; - uniform uColorX: vec3f; - uniform uColorZ: vec3f; - uniform uResolution: u32; - - varying uv0: vec2f; - - // https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8#1e7c - fn pristineGrid(uv: vec2f, ddx: vec2f, ddy: vec2f, lineWidth: vec2f) -> f32 { - let uvDeriv = vec2f(length(vec2f(ddx.x, ddy.x)), length(vec2f(ddx.y, ddy.y))); - let invertLine = vec2(lineWidth.x > 0.5, lineWidth.y > 0.5); - let targetWidth = vec2f( - select(lineWidth.x, 1.0 - lineWidth.x, invertLine.x), - select(lineWidth.y, 1.0 - lineWidth.y, invertLine.y) - ); - let drawWidth = clamp(targetWidth, uvDeriv, vec2f(0.5)); - let lineAA = uvDeriv * 1.5; - var gridUV = abs(fract(uv) * 2.0 - 1.0); - gridUV.x = select(1.0 - gridUV.x, gridUV.x, invertLine.x); - gridUV.y = select(1.0 - gridUV.y, gridUV.y, invertLine.y); - var grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); - - grid2 *= clamp(targetWidth / drawWidth, vec2f(0.0), vec2f(1.0)); - grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, vec2f(0.0), vec2f(1.0))); - grid2.x = select(grid2.x, 1.0 - grid2.x, invertLine.x); - grid2.y = select(grid2.y, 1.0 - grid2.y, invertLine.y); - - return mix(grid2.x, 1.0, grid2.y); - } - - @fragment - fn fragmentMain(input: FragmentInput) -> FragmentOutput { - var output: FragmentOutput; - let uv: vec2f = input.uv0; - - let pos: vec2f = (uv * 2.0 - 1.0) * uniform.uHalfExtents; - let ddx: vec2f = dpdx(pos); - let ddy: vec2f = dpdy(pos); - - let epsilon: f32 = 1.0 / 255.0; - - var levelPos: vec2f; - var levelSize: f32; - var levelAlpha: f32; - - levelPos = pos * 0.1; - levelSize = 2.0 / 1000.0; - levelAlpha = pristineGrid(levelPos, ddx * 0.1, ddy * 0.1, vec2f(levelSize)); - if (levelAlpha > epsilon) { - var color: vec3f; - if (abs(levelPos.x) < levelSize) { - if (abs(levelPos.y) < levelSize) { - color = vec3f(1.0); - } else { - color = uniform.uColorZ; - } - } else if (abs(levelPos.y) < levelSize) { - color = uniform.uColorX; - } else { - color = vec3f(0.9); - } - output.color = vec4f(color, levelAlpha); - return output; - } - - levelPos = pos; - levelSize = 1.0 / 100.0; - levelAlpha = pristineGrid(levelPos, ddx, ddy, vec2f(levelSize)); - if (levelAlpha > epsilon) { - if (uniform.uResolution < 1) { - discard; - } - output.color = vec4f(vec3f(0.7), levelAlpha); - return output; - } +import { Color, Grid as EngineGrid, Script, Vec2 } from 'playcanvas'; - levelPos = pos * 10.0; - levelSize = 1.0 / 100.0; - levelAlpha = pristineGrid(levelPos, ddx * 10.0, ddy * 10.0, vec2f(levelSize)); - if (levelAlpha > epsilon) { - if (uniform.uResolution < 2) { - discard; - } - output.color = vec4f(vec3f(0.7), levelAlpha); - return output; - } +// warn once about the deprecated resolution +let resolutionWarned = false; - discard; - return output; - } -`; +/** + * @import { AppBase, Entity } from 'playcanvas' + */ /** - * Renders an anti-aliased reference grid on the XZ plane, using the "pristine grid" shader - * technique. Lines along the X and Z axes are tinted with {@link Grid#colorX} and - * {@link Grid#colorZ}, and {@link Grid#resolution} selects how many grid levels are shown. - * - * The script creates a render component on its entity, with the grid plane sized by the - * entity's local scale. + * Renders the engine's {@link EngineGrid | Grid} through a render component on its entity. The + * entity's scale sizes the grid when it isn't {@link Grid#infinite}. * * @example - * gridEntity.setLocalScale(8, 1, 8); * gridEntity.addComponent('script'); * gridEntity.script.create(Grid, { * properties: { - * resolution: Grid.RESOLUTION_HIGH + * spacing: 1 * } * }); * @category Rendering @@ -255,54 +25,47 @@ class Grid extends Script { /** * @type {number} + * @deprecated Has no effect, as the levels follow the zoom. + * @ignore */ static RESOLUTION_LOW = 0; /** * @type {number} + * @deprecated Has no effect, as the levels follow the zoom. + * @ignore */ static RESOLUTION_MEDIUM = 1; /** * @type {number} + * @deprecated Has no effect, as the levels follow the zoom. + * @ignore */ static RESOLUTION_HIGH = 2; /** - * @type {ShaderMaterial} + * The engine grid this script drives. + * + * @type {EngineGrid} * @private */ - _material; + _grid = new EngineGrid(this.app.graphicsDevice); /** - * @type {MeshInstance} - * @private + * @param {{ app: AppBase, entity: Entity }} args - The script arguments. */ - _meshInstance; + constructor(args) { + super(args); - /** - * @type {Vec2} - * @private - */ - _halfExtents = new Vec2(); - - /** - * @type {Color} - * @private - */ - _colorX = new Color(1, 0.3, 0.3); - - /** - * @type {Color} - * @private - */ - _colorZ = new Color(0.3, 0.3, 1); - - /** - * @type {number} - * @private - */ - _resolution = Grid.RESOLUTION_HIGH; + // release the grid even if the script never initializes + this.on('destroy', () => { + if (this.entity.render?.meshInstances === this._grid.meshInstances) { + this.entity.removeComponent('render'); + } + this._grid.destroy(); + }); + } initialize() { // check if the entity already has a render component @@ -311,156 +74,334 @@ class Grid extends Script { return; } - // create render component this.entity.addComponent('render', { + meshInstances: this._grid.meshInstances, castShadows: false }); - // create shader material - this._material = new ShaderMaterial({ - uniqueName: 'grid-shader', - vertexGLSL: vertexGLSL, - fragmentGLSL: fragmentGLSL, - vertexWGSL: vertexWGSL, - fragmentWGSL: fragmentWGSL, - attributes: { - vertex_position: SEMANTIC_POSITION, - aUv0: SEMANTIC_TEXCOORD0 - } - }); - this._material.blendType = BLEND_NORMAL; - this._material.cull = CULLFACE_NONE; - this._material.update(); - - // create mesh - const mesh = Mesh.fromGeometry(this.app.graphicsDevice, new PlaneGeometry()); - this._meshInstance = new MeshInstance(mesh, this._material); - this._meshInstance.pick = false; - this.entity.render.meshInstances = [this._meshInstance]; - - // set the initial values - this.colorX = this._colorX; - this.colorZ = this._colorZ; - this.resolution = this._resolution; - - // calculate half extents - this._set('uHalfExtents', this._calcHalfExtents(tmpVa)); - - // update the half extents when the entity scale changes - this.app.on('prerender', () => { - if (!this.enabled) { - return; - } - const halfExtents = this._calcHalfExtents(tmpVa); - if (this._halfExtents.distance(halfExtents) > EPSILON) { - this._set('uHalfExtents', halfExtents); - } - }); - - // enable/disable the mesh instance this.on('enable', () => { - this._meshInstance.visible = true; + this._grid.visible = true; }); this.on('disable', () => { - this._meshInstance.visible = false; + this._grid.visible = false; }); + } - this.on('destroy', this.destroy, this); + /** + * @attribute + * @title Plane + * @description The plane in the entity's local space: 'xz', 'xy' or 'yz'. + * @type {string} + * @default xz + */ + set plane(value) { + this._grid.plane = value; + } + + get plane() { + return this._grid.plane; } /** - * @param {Vec2} vec - The vector to copy the half extents to. - * @returns {Vec2} - The half extents. - * @private + * @attribute + * @title Spacing + * @description The cell size the levels are scaled from, in world units. + * @type {number} + * @default 10 */ - _calcHalfExtents(vec) { - const scale = this.entity.getLocalScale(); - return vec.set(scale.x / 2, scale.z / 2); + set spacing(value) { + this._grid.spacing = value; + } + + get spacing() { + return this._grid.spacing; } /** - * @param {string} name - The name of the parameter. - * @param {Color|Vec2|number} value - The value of the parameter. - * @private + * @attribute + * @title Subdivisions + * @description How many cells of a level fit across one of the level above, or 1 for a + * single fixed level. + * @type {number} + * @default 10 */ - _set(name, value) { - if (!this._material) { - return; - } + set subdivisions(value) { + this._grid.subdivisions = value; + } - if (value instanceof Color) { - this._material.setParameter(name, [value.r, value.g, value.b]); - } + get subdivisions() { + return this._grid.subdivisions; + } + + /** + * @attribute + * @title Infinite + * @description Whether the grid has no edge. When false, the entity's scale sizes it. + * @type {boolean} + * @default true + */ + set infinite(value) { + this._grid.infinite = value; + } + + get infinite() { + return this._grid.infinite; + } + + /** + * @attribute + * @title Show Axes + * @description Whether the lines through the origin use the axis colors. + * @type {boolean} + * @default true + */ + set showAxes(value) { + this._grid.showAxes = value; + } + + get showAxes() { + return this._grid.showAxes; + } + + /** + * @attribute + * @title Axis Line Thickness + * @description The axis line thickness in CSS pixels. + * @type {number} + * @default 2 + */ + set axisLineThickness(value) { + this._grid.axisLineThickness = value; + } + + get axisLineThickness() { + return this._grid.axisLineThickness; + } + + /** + * @attribute + * @title Major Line Thickness + * @description The major line thickness in CSS pixels. + * @type {number} + * @default 1.5 + */ + set majorLineThickness(value) { + this._grid.majorLineThickness = value; + } + + get majorLineThickness() { + return this._grid.majorLineThickness; + } + + /** + * @attribute + * @title Minor Line Thickness + * @description The minor line thickness in CSS pixels. + * @type {number} + * @default 1 + */ + set minorLineThickness(value) { + this._grid.minorLineThickness = value; + } + get minorLineThickness() { + return this._grid.minorLineThickness; + } + + /** + * @attribute + * @title Fade Range + * @description The distances from the camera where the grid starts and finishes fading out. + * @type {Vec2} + * @default [400, 1000] + */ + set fadeRange(value) { if (value instanceof Vec2) { - this._material.setParameter(name, [value.x, value.y]); + this._grid.fadeRange = value; } + } - if (typeof value === 'number') { - this._material.setParameter(name, value); - } + get fadeRange() { + return this._grid.fadeRange; + } - this._material.update(); + /** + * @attribute + * @title Contrast + * @description Whether the lines are black over light parts of the scene and white over dark + * ones. Needs the camera's scene color map. + * @type {boolean} + * @default false + */ + set contrast(value) { + this._grid.contrast = value; + } - this._meshInstance.material = this._material; + get contrast() { + return this._grid.contrast; } /** * @attribute - * @title Grid Color X - * @description The color of the grid lines along the X axis. + * @title Axis Color X + * @description The X axis line color. * @type {Color} * @default [1, 0.3, 0.3, 1] */ set colorX(value) { - if (!(value instanceof Color)) { - return; + if (value instanceof Color) { + this._grid.setTheme({ axis: { x: value } }); } - this._colorX.copy(value); - this._set('uColorX', this._colorX); } get colorX() { - return this._colorX; + return this._grid.theme.axis.x; } /** * @attribute - * @title Grid Color Z - * @description The color of the grid lines along the Z axis. + * @title Axis Color Y + * @description The Y axis line color. + * @type {Color} + * @default [0.3, 1, 0.3, 1] + */ + set colorY(value) { + if (value instanceof Color) { + this._grid.setTheme({ axis: { y: value } }); + } + } + + get colorY() { + return this._grid.theme.axis.y; + } + + /** + * @attribute + * @title Axis Color Z + * @description The Z axis line color. * @type {Color} * @default [0.3, 0.3, 1, 1] */ set colorZ(value) { - if (!(value instanceof Color)) { - return; + if (value instanceof Color) { + this._grid.setTheme({ axis: { z: value } }); } - this._colorZ.copy(value); - this._set('uColorZ', this._colorZ); } get colorZ() { - return this._colorZ; + return this._grid.theme.axis.z; + } + + /** + * @attribute + * @title Major Line Color + * @description The major line color. The alpha sets the opacity. + * @type {Color} + * @default [0.8, 0.8, 0.8, 0.6] + */ + set majorLineColor(value) { + if (value instanceof Color) { + this._grid.setTheme({ major: value }); + } + } + + get majorLineColor() { + return this._grid.theme.major; + } + + /** + * @attribute + * @title Minor Line Color + * @description The minor line color. The alpha sets the opacity. + * @type {Color} + * @default [0.6, 0.6, 0.6, 0.4] + */ + set minorLineColor(value) { + if (value instanceof Color) { + this._grid.setTheme({ minor: value }); + } + } + + get minorLineColor() { + return this._grid.theme.minor; } /** * @attribute - * @title Grid Resolution - * @description The resolution of the grid. + * @title Depth Offset + * @description The fraction of the camera distance the grid's depth moves toward the camera + * by, so a coplanar surface doesn't hide it. * @type {number} - * @default 2 + * @default 0.001 */ - set resolution(value) { - this._resolution = value; - this._set('uResolution', this._resolution); + set depthOffset(value) { + this._grid.depthOffset = value; } - get resolution() { - return this._resolution; + get depthOffset() { + return this._grid.depthOffset; } - destroy() { - this.entity.removeComponent('render'); + /** + * @attribute + * @title Depth Test + * @description Whether the grid is depth tested against the scene. + * @type {boolean} + * @default true + */ + set depthTest(value) { + this._grid.depthTest = value; + } + + get depthTest() { + return this._grid.depthTest; + } + + /** + * @attribute + * @title Depth Write + * @description Whether the lines write depth, hiding transparent meshes and gaussian splats + * behind them. + * @type {boolean} + * @default false + */ + set depthWrite(value) { + this._grid.depthWrite = value; + } + + get depthWrite() { + return this._grid.depthWrite; + } + + /** + * @attribute + * @title Depth Dither + * @description Whether the written depth is dithered by line coverage, for gaussian splats. + * @type {boolean} + * @default false + */ + set depthDither(value) { + this._grid.depthDither = value; + } + + get depthDither() { + return this._grid.depthDither; + } + + /** + * @type {number} + * @deprecated Has no effect, as the levels follow the zoom. + * @ignore + */ + set resolution(value) { + if (!resolutionWarned) { + resolutionWarned = true; + console.warn('Grid#resolution is deprecated and has no effect, as the levels follow the zoom.'); + } + } + + get resolution() { + return Grid.RESOLUTION_HIGH; } } diff --git a/src/extras/grid/grid.js b/src/extras/grid/grid.js new file mode 100644 index 00000000000..0b0f47f01e4 --- /dev/null +++ b/src/extras/grid/grid.js @@ -0,0 +1,716 @@ +import { Color } from '../../core/math/color.js'; +import { Vec2 } from '../../core/math/vec2.js'; +import { CULLFACE_NONE, SEMANTIC_POSITION } from '../../platform/graphics/constants.js'; +import { BLEND_NORMAL } from '../../scene/constants.js'; +import { PlaneGeometry } from '../../scene/geometry/plane-geometry.js'; +import { ShaderMaterial } from '../../scene/materials/shader-material.js'; +import { MeshInstance } from '../../scene/mesh-instance.js'; +import { Mesh } from '../../scene/mesh.js'; +import { fragmentGLSL, fragmentWGSL, vertexGLSL, vertexWGSL } from './shaders.js'; + +/** + * @import { CameraComponent } from '../../framework/components/camera/component.js' + * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' + */ + +/** + * @typedef {object} GridTheme + * @property {{ x: Color, y: Color, z: Color }} axis - The axis line colors. + * @property {Color} major - The major line color. + * @property {Color} minor - The minor line color. + */ + +/** + * The plane of a {@link Grid} in its node's local space: 'xz', 'xy' or 'yz'. + * + * @typedef {'xz' | 'xy' | 'yz'} GridPlane + */ + +// local axes of each plane's u and v coordinates +const PLANES = { + xz: [[1, 0, 0], [0, 0, 1]], + xy: [[1, 0, 0], [0, 1, 0]], + yz: [[0, 1, 0], [0, 0, 1]] +}; + +/** + * An anti-aliased reference grid whose levels of lines follow the zoom. It follows the camera and + * fades out with distance, or is sized by its node's scale when not {@link Grid#infinite}. It lies + * at its node's position and rotation; use {@link Grid#spacing} to size the cells. + * + * ```javascript + * const grid = new Grid(app.graphicsDevice); + * entity.addComponent('render', { + * meshInstances: grid.meshInstances, + * castShadows: false + * }); + * ``` + * + * Relevant Engine API examples: + * + * - [Grid](https://playcanvas.github.io/#/graphics/grid) + * + * @category Graphics + */ +class Grid { + /** + * @type {GraphicsDevice} + * @private + */ + _device; + + /** + * @type {ShaderMaterial} + * @private + */ + _material; + + /** + * Writes the depth of the lines. + * + * @type {ShaderMaterial} + * @private + */ + _depthMaterial; + + /** + * @type {MeshInstance[]} + * @private + */ + _meshInstances; + + /** + * @type {GridTheme} + * @private + */ + _theme = { + axis: { + x: new Color(1, 0.3, 0.3), + y: new Color(0.3, 1, 0.3), + z: new Color(0.3, 0.3, 1) + }, + major: new Color(0.8, 0.8, 0.8, 0.6), + minor: new Color(0.6, 0.6, 0.6, 0.4) + }; + + /** + * @type {GridPlane} + * @private + */ + _plane = 'xz'; + + /** + * @type {boolean} + * @private + */ + _showAxes = true; + + /** + * @type {boolean} + * @private + */ + _contrast = false; + + /** + * @type {number} + * @private + */ + _spacing = 10; + + /** + * @type {number} + * @private + */ + _subdivisions = 10; + + /** + * @type {number} + * @private + */ + _majorLineThickness = 1.5; + + /** + * @type {number} + * @private + */ + _minorLineThickness = 1; + + /** + * @type {number} + * @private + */ + _axisLineThickness = 2; + + /** + * CSS pixels to device pixels. + * + * @type {number} + * @private + */ + _pixelRatio = 1; + + /** + * @type {Vec2} + * @private + */ + _fadeRange = new Vec2(400, 1000); + + /** + * @type {boolean} + * @private + */ + _infinite = true; + + /** + * @type {number} + * @private + */ + _depthOffset = 0.001; + + /** + * @type {boolean} + * @private + */ + _depthTest = true; + + /** + * @type {boolean} + * @private + */ + _depthWrite = false; + + /** + * @type {boolean} + * @private + */ + _depthDither = false; + + /** + * @type {boolean} + * @private + */ + _visible = true; + + /** + * Creates a new Grid instance. + * + * @param {GraphicsDevice} device - The graphics device. + * @example + * const grid = new Grid(app.graphicsDevice); + * grid.spacing = 1; + * grid.plane = 'xy'; + */ + constructor(device) { + this._device = device; + this._updatePixelRatio = this._updatePixelRatio.bind(this); + device.on('resizecanvas', this._updatePixelRatio); + + this._material = this._createMaterial(false); + this._depthMaterial = this._createMaterial(true); + + // moved in the vertex shader, so never culled; drawn first of the transparents so its depth + // can hide them + const mesh = Mesh.fromGeometry(device, new PlaneGeometry()); + this._meshInstances = [this._material, this._depthMaterial].map((material) => { + const meshInstance = new MeshInstance(mesh, material); + meshInstance.cull = false; + meshInstance.pick = false; + meshInstance.drawBucket = 255; + return meshInstance; + }); + + this._updatePlane(); + this._updateColor('uMajorColor', this._theme.major); + this._updateColor('uMinorColor', this._theme.minor); + this._setParameter('uSpacing', this._spacing); + this._updateLevels(); + this._updatePixelRatio(); + this._updateFade(); + this._updateInfinite(); + this._setParameter('uShowAxes', 1); + this._setParameter('uDepthOffset', this._depthOffset); + this._updateVisible(); + } + + /** + * Gets the mesh instances to add to a render component or layer: the lines and their depth + * pass. They have no node, so set one when adding them to a layer directly. + * + * @type {MeshInstance[]} + */ + get meshInstances() { + return this._meshInstances; + } + + /** + * Gets the theme. + * + * @type {GridTheme} + */ + get theme() { + return this._theme; + } + + /** + * Sets the theme or part of it. Each color's alpha sets its lines' opacity. + * + * @param {{ axis?: Partial, major?: Color, minor?: Color }} partial - The + * theme to set. + * @example + * grid.setTheme({ + * axis: { x: new Color(1, 0, 0) }, + * minor: new Color(0.5, 0.5, 0.5, 0.5) + * }); + */ + setTheme(partial) { + const theme = this._theme; + for (const axis of /** @type {const} */ (['x', 'y', 'z'])) { + const color = partial?.axis?.[axis]; + if (color instanceof Color) { + theme.axis[axis].copy(color); + } + } + if (partial?.major instanceof Color) { + theme.major.copy(partial.major); + } + if (partial?.minor instanceof Color) { + theme.minor.copy(partial.minor); + } + this._updatePlane(); + this._updateColor('uMajorColor', theme.major); + this._updateColor('uMinorColor', theme.minor); + } + + /** + * Sets the plane in the node's local space. Defaults to 'xz'. + * + * @type {GridPlane} + */ + set plane(value) { + if (!PLANES[value]) { + return; + } + this._plane = value; + this._updatePlane(); + } + + /** + * Gets the plane. + * + * @type {GridPlane} + */ + get plane() { + return this._plane; + } + + /** + * Sets whether the lines through the origin use the axis colors. Defaults to true. + * + * @type {boolean} + */ + set showAxes(value) { + this._showAxes = !!value; + this._setParameter('uShowAxes', this._showAxes ? 1 : 0); + } + + /** + * Gets whether the lines through the origin use the axis colors. + * + * @type {boolean} + */ + get showAxes() { + return this._showAxes; + } + + /** + * Sets whether the lines are black over light parts of the scene and white over dark ones, + * instead of their theme colors. Needs the scene color map, see + * {@link CameraComponent#requestSceneColorMap}. Defaults to false. + * + * @type {boolean} + */ + set contrast(value) { + this._contrast = !!value; + this._material.setDefine('GRID_CONTRAST', this._contrast); + this._material.update(); + } + + /** + * Gets whether the lines contrast with the scene. + * + * @type {boolean} + */ + get contrast() { + return this._contrast; + } + + /** + * Sets the cell size the levels are scaled from, in world units. Defaults to 10. + * + * @type {number} + */ + set spacing(value) { + this._spacing = Math.max(value, 1e-6); + this._setParameter('uSpacing', this._spacing); + } + + /** + * Gets the cell size the levels are scaled from. + * + * @type {number} + */ + get spacing() { + return this._spacing; + } + + /** + * Sets how many cells of a level fit across one of the level above, or 1 for a single fixed + * level. Defaults to 10. + * + * @type {number} + */ + set subdivisions(value) { + this._subdivisions = Math.max(value, 1); + this._updateLevels(); + } + + /** + * Gets how many cells of a level fit across one of the level above. + * + * @type {number} + */ + get subdivisions() { + return this._subdivisions; + } + + /** + * Sets the major line thickness in CSS pixels. Defaults to 1.5. + * + * @type {number} + */ + set majorLineThickness(value) { + this._majorLineThickness = value; + this._updateThickness(); + } + + /** + * Gets the major line thickness in CSS pixels. + * + * @type {number} + */ + get majorLineThickness() { + return this._majorLineThickness; + } + + /** + * Sets the minor line thickness in CSS pixels. Defaults to 1. + * + * @type {number} + */ + set minorLineThickness(value) { + this._minorLineThickness = value; + this._updateThickness(); + } + + /** + * Gets the minor line thickness in CSS pixels. + * + * @type {number} + */ + get minorLineThickness() { + return this._minorLineThickness; + } + + /** + * Sets the axis line thickness in CSS pixels. Defaults to 2. + * + * @type {number} + */ + set axisLineThickness(value) { + this._axisLineThickness = value; + this._updateThickness(); + } + + /** + * Gets the axis line thickness in CSS pixels. + * + * @type {number} + */ + get axisLineThickness() { + return this._axisLineThickness; + } + + /** + * Sets the distances from the camera where the grid starts and finishes fading out. The end is + * kept within 90% of the far clip. Defaults to [400, 1000]. + * + * @type {Vec2} + */ + set fadeRange(value) { + this._fadeRange.copy(value); + this._updateFade(); + } + + /** + * Gets the distances from the camera where the grid starts and finishes fading out. + * + * @type {Vec2} + */ + get fadeRange() { + return this._fadeRange; + } + + /** + * Sets whether the grid has no edge. When false, it is sized by its node's scale along the + * plane's axes. Defaults to true. + * + * @type {boolean} + */ + set infinite(value) { + this._infinite = !!value; + this._updateInfinite(); + } + + /** + * Gets whether the grid has no edge. + * + * @type {boolean} + */ + get infinite() { + return this._infinite; + } + + /** + * Sets the fraction of the camera distance the grid's depth moves toward the camera by, so a + * coplanar surface doesn't hide it. Defaults to 0.001. + * + * @type {number} + */ + set depthOffset(value) { + this._depthOffset = value; + this._setParameter('uDepthOffset', value); + } + + /** + * Gets the fraction of the camera distance the grid's depth moves toward the camera by. + * + * @type {number} + */ + get depthOffset() { + return this._depthOffset; + } + + /** + * Sets whether the grid is depth tested against the scene. Defaults to true. + * + * @type {boolean} + */ + set depthTest(value) { + this._depthTest = !!value; + this._material.depthTest = this._depthTest; + this._material.update(); + this._depthMaterial.depthTest = this._depthTest; + this._depthMaterial.update(); + } + + /** + * Gets whether the grid is depth tested against the scene. + * + * @type {boolean} + */ + get depthTest() { + return this._depthTest; + } + + /** + * Sets whether the lines write depth, hiding transparent meshes and gaussian splats behind + * them. Defaults to false. + * + * @type {boolean} + */ + set depthWrite(value) { + this._depthWrite = !!value; + this._updateVisible(); + } + + /** + * Gets whether the lines write depth. + * + * @type {boolean} + */ + get depthWrite() { + return this._depthWrite; + } + + /** + * Sets whether the written depth is dithered by line coverage, so faint lines partly hide what + * is behind them. Suits gaussian splats. Defaults to false. + * + * @type {boolean} + */ + set depthDither(value) { + this._depthDither = !!value; + this._depthMaterial.setDefine('GRID_DEPTH_DITHER', this._depthDither); + this._depthMaterial.update(); + } + + /** + * Gets whether the written depth is dithered. + * + * @type {boolean} + */ + get depthDither() { + return this._depthDither; + } + + /** + * Sets whether the grid is visible. Defaults to true. + * + * @type {boolean} + */ + set visible(value) { + this._visible = !!value; + this._updateVisible(); + } + + /** + * Gets whether the grid is visible. + * + * @type {boolean} + */ + get visible() { + return this._visible; + } + + /** + * Creates a grid material. + * + * @param {boolean} depth - Whether it writes depth instead of color. + * @returns {ShaderMaterial} The material. + * @private + */ + _createMaterial(depth) { + const material = new ShaderMaterial({ + uniqueName: 'grid', + vertexGLSL, + fragmentGLSL, + vertexWGSL, + fragmentWGSL, + attributes: { + vertex_position: SEMANTIC_POSITION + } + }); + // both blended, so they draw after the opaque meshes + material.cull = CULLFACE_NONE; + material.blendType = BLEND_NORMAL; + material.depthWrite = depth; + if (depth) { + material.setDefine('GRID_DEPTH', true); + material.redWrite = false; + material.greenWrite = false; + material.blueWrite = false; + material.alphaWrite = false; + } + material.update(); + return material; + } + + /** + * Sets a parameter on both materials. + * + * @param {string} name - The name of the parameter. + * @param {number|number[]} value - The value of the parameter. + * @private + */ + _setParameter(name, value) { + this._material.setParameter(name, value); + this._depthMaterial.setParameter(name, value); + } + + /** + * @param {string} name - The name of the parameter. + * @param {Color} color - The color. + * @private + */ + _updateColor(name, color) { + this._setParameter(name, [color.r, color.g, color.b, color.a]); + } + + /** + * @private + */ + _updatePlane() { + const plane = this._plane; + const { axis } = this._theme; + const [u, v] = PLANES[plane]; + this._setParameter('uAxisU', u); + this._setParameter('uAxisV', v); + this._updateColor('uColorU', plane === 'yz' ? axis.y : axis.x); + this._updateColor('uColorV', plane === 'xy' ? axis.y : axis.z); + } + + /** + * @private + */ + _updateLevels() { + this._setParameter('uSubdivisions', this._subdivisions); + } + + /** + * @private + */ + _updatePixelRatio() { + const device = this._device; + device.updateClientRect(); + const { width } = device.clientRect; + this._pixelRatio = width > 0 ? device.width / width : 1; + this._setParameter('uPixelRatio', this._pixelRatio); + this._updateThickness(); + } + + /** + * @private + */ + _updateThickness() { + const ratio = this._pixelRatio; + this._setParameter('uThickness', [ + this._majorLineThickness * ratio, + this._minorLineThickness * ratio, + this._axisLineThickness * ratio + ]); + } + + /** + * @private + */ + _updateFade() { + this._setParameter('uFade', [this._fadeRange.x, this._fadeRange.y]); + } + + /** + * @private + */ + _updateInfinite() { + this._setParameter('uInfinite', this._infinite ? 1 : 0); + } + + /** + * @private + */ + _updateVisible() { + this._meshInstances[0].visible = this._visible; + this._meshInstances[1].visible = this._visible && this._depthWrite; + } + + /** + * Destroys the mesh instances and materials. Remove the mesh instances from use first. + */ + destroy() { + this._device.off('resizecanvas', this._updatePixelRatio); + this._meshInstances.forEach(meshInstance => meshInstance.destroy()); + this._material.destroy(); + this._depthMaterial.destroy(); + } +} + +export { Grid }; diff --git a/src/extras/grid/shaders.js b/src/extras/grid/shaders.js new file mode 100644 index 00000000000..acf336fc198 --- /dev/null +++ b/src/extras/grid/shaders.js @@ -0,0 +1,381 @@ +// fraction of the far clip the fade must end within +const FAR_FRACTION = '0.9'; + +// cell size in CSS pixels below which lines thin with their cells, so distant lines fade +const LINE_FADE_CELL = '64.0'; + +// cell size in CSS pixels where a level starts to fade in, and the levels drawn above it +const LEVEL_FADE_CELL = '4.0'; +const LEVELS_ABOVE = 2; + +export const vertexGLSL = /* glsl */` + attribute vec3 vertex_position; + + uniform mat4 matrix_model; + uniform mat4 matrix_view; + uniform mat4 matrix_viewProjection; + uniform vec3 view_position; + uniform vec4 camera_params; + + uniform vec3 uAxisU; + uniform vec3 uAxisV; + uniform float uInfinite; + uniform vec2 uFade; + uniform float uDepthOffset; + + varying vec2 vGridPos; + varying vec3 vWorldPos; + varying vec4 vClipPos; + varying vec3 vToEye; + varying vec3 vNormal; + + void main(void) { + + // the node's scale sizes a fixed grid, never its cells + vec3 origin = matrix_model[3].xyz; + vec3 axisU = mat3(matrix_model) * uAxisU; + vec3 axisV = mat3(matrix_model) * uAxisV; + vec3 u = normalize(axisU); + vec3 v = normalize(axisV); + + // an infinite grid follows the camera and spans the fade distance + float reach = 2.0 * min(uFade.y, camera_params.y * ${FAR_FRACTION}); + vec2 center = vec2(dot(view_position - origin, u), dot(view_position - origin, v)) * uInfinite; + vGridPos = center + vertex_position.xz * mix(vec2(length(axisU), length(axisV)), vec2(reach), uInfinite); + vWorldPos = origin + u * vGridPos.x + v * vGridPos.y; + + // offset along the view ray, so only depth changes + vec3 back = vec3(matrix_view[0][2], matrix_view[1][2], matrix_view[2][2]); + vec3 toEye = view_position - vWorldPos; + if (camera_params.w > 0.5) { + toEye = back * dot(toEye, back); + } + gl_Position = matrix_viewProjection * vec4(vWorldPos + toEye * uDepthOffset, 1.0); + vClipPos = gl_Position; + vToEye = toEye; + vNormal = cross(u, v); + } +`; + +export const fragmentGLSL = /* glsl */` + #include "gammaPS" + + uniform vec3 view_position; + uniform vec4 camera_params; + + uniform vec2 uFade; + uniform float uSpacing; + uniform float uSubdivisions; + uniform vec3 uThickness; + uniform float uPixelRatio; + uniform float uShowAxes; + uniform vec4 uColorU; + uniform vec4 uColorV; + uniform vec4 uMajorColor; + uniform vec4 uMinorColor; + + #ifdef GRID_CONTRAST + uniform sampler2D uSceneColorMap; + #endif + + #ifdef GRID_DEPTH_DITHER + uniform sampler2D blueNoiseTex32; + #endif + + varying vec2 vGridPos; + varying vec3 vWorldPos; + varying vec4 vClipPos; + varying vec3 vToEye; + varying vec3 vNormal; + + // https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8#1e7c, with lines in pixels + float pristineGrid(vec2 uv, vec2 uvDeriv, float pixels, vec2 fade) { + vec2 targetWidth = min(pixels * uvDeriv, vec2(min(pixels / (${LINE_FADE_CELL} * uPixelRatio), 0.5))); + vec2 drawWidth = clamp(targetWidth, uvDeriv, vec2(0.5)); + vec2 lineAA = uvDeriv * 1.5; + vec2 gridUV = 1.0 - abs(fract(uv) * 2.0 - 1.0); + vec2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); + grid2 *= clamp(targetWidth / drawWidth, 0.0, 1.0); + grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, 0.0, 1.0)); + grid2 *= fade; + return mix(grid2.x, 1.0, grid2.y); + } + + // drops the origin line when the axes draw it + vec2 offAxis(vec2 uv) { + return uShowAxes > 0.5 ? step(0.5, abs(uv)) : vec2(1.0); + } + + void main(void) { + vec2 pos = vGridPos / uSpacing; + vec2 ddx = dFdx(pos); + vec2 ddy = dFdy(pos); + vec2 uvDeriv = vec2(length(vec2(ddx.x, ddy.x)), length(vec2(ddx.y, ddy.y))); + float epsilon = 1.0 / 255.0; + + // the level fading in, by cell size on screen; one fixed level without subdivisions + float lod = uSubdivisions > 1.0 ? + log(${LEVEL_FADE_CELL} * uPixelRatio * max(uvDeriv.x, uvDeriv.y)) / log(uSubdivisions) : + -1.0; + float finest = ceil(lod); + + // coarsest level first; the finest fades in and the coarser turn major + vec4 color = uMinorColor; + float line = 0.0; + for (int i = ${LEVELS_ABOVE}; i >= 0; i--) { + float k = finest + float(i); + float scale = pow(uSubdivisions, -k); + float c = k - lod; + float major = clamp(c - 1.0, 0.0, 1.0); + float l = pristineGrid(pos * scale, uvDeriv * scale, mix(uThickness.y, uThickness.x, major), offAxis(pos * scale)); + l *= clamp(c, 0.0, 1.0); + if (l > epsilon) { + color = mix(uMinorColor, uMajorColor, major); + line = l; + break; + } + } + + // axis lines, from the coarsest level + float topScale = pow(uSubdivisions, -(finest + ${LEVELS_ABOVE}.0)); + vec2 top = pos * topScale; + vec2 topDeriv = uvDeriv * topScale; + float lineU = abs(top.y) < 0.5 ? pristineGrid(top, topDeriv, uThickness.z, vec2(0.0, 1.0)) : 0.0; + float lineV = abs(top.x) < 0.5 ? pristineGrid(top, topDeriv, uThickness.z, vec2(1.0, 0.0)) : 0.0; + float axis = max(lineU, lineV) * uShowAxes; + if (axis > epsilon && axis >= line) { + color = lineU >= lineV ? uColorU : uColorV; + line = axis; + } + #ifdef GRID_CONTRAST + else { + // black over light, white over dark + vec3 under = texture2DLod(uSceneColorMap, (vClipPos.xy / vClipPos.w) * 0.5 + 0.5, 0.0).rgb; + color.rgb = vec3(1.0 - smoothstep(0.4, 0.6, dot(under, vec3(0.2126, 0.7152, 0.0722)))); + } + #endif + + // distance fade + float fadeEnd = min(uFade.y, camera_params.y * ${FAR_FRACTION}); + float fadeStart = min(uFade.x, fadeEnd); + float f = clamp((length(vWorldPos - view_position) - fadeStart) / max(fadeEnd - fadeStart, 1e-5), 0.0, 1.0); + + // grazing angle fade + float grazing = 1.0 - pow(1.0 - abs(dot(normalize(vToEye), normalize(vNormal))), 3.0); + float coverage = line * (1.0 - f * f * (3.0 - 2.0 * f)) * grazing; + float alpha = coverage * color.a; + if (alpha <= epsilon) { + discard; + } + + #ifdef GRID_DEPTH + // write depth where coverage beats the noise, or half + #ifdef GRID_DEPTH_DITHER + float threshold = texture2DLod(blueNoiseTex32, fract(gl_FragCoord.xy / 32.0), 0.0).y; + #else + float threshold = 0.5; + #endif + if (coverage < threshold) { + discard; + } + gl_FragColor = vec4(0.0); + #else + gl_FragColor = vec4(gammaCorrectOutput(decodeGamma(color.rgb)), alpha); + #endif + } +`; + +export const vertexWGSL = /* wgsl */` + attribute vertex_position: vec3f; + + uniform matrix_model: mat4x4f; + uniform matrix_view: mat4x4f; + uniform matrix_viewProjection: mat4x4f; + uniform view_position: vec3f; + uniform camera_params: vec4f; + + uniform uAxisU: vec3f; + uniform uAxisV: vec3f; + uniform uInfinite: f32; + uniform uFade: vec2f; + uniform uDepthOffset: f32; + + varying vGridPos: vec2f; + varying vWorldPos: vec3f; + varying vClipPos: vec4f; + varying vToEye: vec3f; + varying vNormal: vec3f; + + @vertex + fn vertexMain(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + + // the node's scale sizes a fixed grid, never its cells + let model = uniform.matrix_model; + let origin = model[3].xyz; + let axisU = (model * vec4f(uniform.uAxisU, 0.0)).xyz; + let axisV = (model * vec4f(uniform.uAxisV, 0.0)).xyz; + let u = normalize(axisU); + let v = normalize(axisV); + + // an infinite grid follows the camera and spans the fade distance + let reach = 2.0 * min(uniform.uFade.y, uniform.camera_params.y * ${FAR_FRACTION}); + let center = vec2f(dot(uniform.view_position - origin, u), dot(uniform.view_position - origin, v)) * uniform.uInfinite; + let gridPos = center + input.vertex_position.xz * mix(vec2f(length(axisU), length(axisV)), vec2f(reach), uniform.uInfinite); + let worldPos = origin + u * gridPos.x + v * gridPos.y; + output.vGridPos = gridPos; + output.vWorldPos = worldPos; + + // offset along the view ray, so only depth changes + let view = uniform.matrix_view; + let back = vec3f(view[0][2], view[1][2], view[2][2]); + var toEye = uniform.view_position - worldPos; + if (uniform.camera_params.w > 0.5) { + toEye = back * dot(toEye, back); + } + output.position = uniform.matrix_viewProjection * vec4f(worldPos + toEye * uniform.uDepthOffset, 1.0); + output.vClipPos = output.position; + output.vToEye = toEye; + output.vNormal = cross(u, v); + return output; + } +`; + +export const fragmentWGSL = /* wgsl */` + #include "gammaPS" + + uniform view_position: vec3f; + uniform camera_params: vec4f; + + uniform uFade: vec2f; + uniform uSpacing: f32; + uniform uSubdivisions: f32; + uniform uThickness: vec3f; + uniform uPixelRatio: f32; + uniform uShowAxes: f32; + uniform uColorU: vec4f; + uniform uColorV: vec4f; + uniform uMajorColor: vec4f; + uniform uMinorColor: vec4f; + + #ifdef GRID_CONTRAST + var uSceneColorMap: texture_2d; + var uSceneColorMapSampler: sampler; + #endif + + #ifdef GRID_DEPTH_DITHER + var blueNoiseTex32: texture_2d; + var blueNoiseTex32Sampler: sampler; + #endif + + varying vGridPos: vec2f; + varying vWorldPos: vec3f; + varying vClipPos: vec4f; + varying vToEye: vec3f; + varying vNormal: vec3f; + + // https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8#1e7c, with lines in pixels + fn pristineGrid(uv: vec2f, uvDeriv: vec2f, pixels: f32, fade: vec2f) -> f32 { + let targetWidth = min(pixels * uvDeriv, vec2f(min(pixels / (${LINE_FADE_CELL} * uniform.uPixelRatio), 0.5))); + let drawWidth = clamp(targetWidth, uvDeriv, vec2f(0.5)); + let lineAA = uvDeriv * 1.5; + let gridUV = 1.0 - abs(fract(uv) * 2.0 - 1.0); + var grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); + grid2 *= clamp(targetWidth / drawWidth, vec2f(0.0), vec2f(1.0)); + grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, vec2f(0.0), vec2f(1.0))); + grid2 *= fade; + return mix(grid2.x, 1.0, grid2.y); + } + + // drops the origin line when the axes draw it + fn offAxis(uv: vec2f) -> vec2f { + return select(vec2f(1.0), step(vec2f(0.5), abs(uv)), uniform.uShowAxes > 0.5); + } + + @fragment + fn fragmentMain(input: FragmentInput) -> FragmentOutput { + var output: FragmentOutput; + + // derivatives need uniform control flow + let pos = input.vGridPos / uniform.uSpacing; + let ddx = dpdx(pos); + let ddy = dpdy(pos); + let uvDeriv = vec2f(length(vec2f(ddx.x, ddy.x)), length(vec2f(ddx.y, ddy.y))); + let epsilon = 1.0 / 255.0; + + // the level fading in, by cell size on screen; one fixed level without subdivisions + let lod = select( + -1.0, + log(${LEVEL_FADE_CELL} * uniform.uPixelRatio * max(uvDeriv.x, uvDeriv.y)) / log(uniform.uSubdivisions), + uniform.uSubdivisions > 1.0 + ); + let finest = ceil(lod); + + // coarsest level first; the finest fades in and the coarser turn major + var color = uniform.uMinorColor; + var line = 0.0; + for (var i: i32 = ${LEVELS_ABOVE}; i >= 0; i--) { + let k = finest + f32(i); + let scale = pow(uniform.uSubdivisions, -k); + let c = k - lod; + let major = clamp(c - 1.0, 0.0, 1.0); + let thickness = mix(uniform.uThickness.y, uniform.uThickness.x, major); + let l = pristineGrid(pos * scale, uvDeriv * scale, thickness, offAxis(pos * scale)) * clamp(c, 0.0, 1.0); + if (l > epsilon) { + color = mix(uniform.uMinorColor, uniform.uMajorColor, major); + line = l; + break; + } + } + + // axis lines, from the coarsest level + let topScale = pow(uniform.uSubdivisions, -(finest + ${LEVELS_ABOVE}.0)); + let top = pos * topScale; + let topDeriv = uvDeriv * topScale; + let lineU = select(0.0, pristineGrid(top, topDeriv, uniform.uThickness.z, vec2f(0.0, 1.0)), abs(top.y) < 0.5); + let lineV = select(0.0, pristineGrid(top, topDeriv, uniform.uThickness.z, vec2f(1.0, 0.0)), abs(top.x) < 0.5); + let axis = max(lineU, lineV) * uniform.uShowAxes; + if (axis > epsilon && axis >= line) { + color = select(uniform.uColorV, uniform.uColorU, lineU >= lineV); + line = axis; + } + #ifdef GRID_CONTRAST + else { + // black over light, white over dark + var uv = (input.vClipPos.xy / input.vClipPos.w) * 0.5 + vec2f(0.5); + uv.y = 1.0 - uv.y; + let under = textureSampleLevel(uSceneColorMap, uSceneColorMapSampler, uv, 0.0).rgb; + color = vec4f(vec3f(1.0 - smoothstep(0.4, 0.6, dot(under, vec3f(0.2126, 0.7152, 0.0722)))), color.a); + } + #endif + + // distance fade + let fadeEnd = min(uniform.uFade.y, uniform.camera_params.y * ${FAR_FRACTION}); + let fadeStart = min(uniform.uFade.x, fadeEnd); + let f = clamp((length(input.vWorldPos - uniform.view_position) - fadeStart) / max(fadeEnd - fadeStart, 1e-5), 0.0, 1.0); + + // grazing angle fade + let grazing = 1.0 - pow(1.0 - abs(dot(normalize(input.vToEye), normalize(input.vNormal))), 3.0); + let coverage = line * (1.0 - f * f * (3.0 - 2.0 * f)) * grazing; + let alpha = coverage * color.a; + if (alpha <= epsilon) { + discard; + } + + #ifdef GRID_DEPTH + // write depth where coverage beats the noise, or half + #ifdef GRID_DEPTH_DITHER + let threshold = textureSampleLevel(blueNoiseTex32, blueNoiseTex32Sampler, fract(pcPosition.xy / 32.0), 0.0).y; + #else + let threshold = 0.5; + #endif + if (coverage < threshold) { + discard; + } + output.color = vec4f(0.0); + #else + output.color = vec4f(gammaCorrectOutput(decodeGamma3(color.rgb)), alpha); + #endif + return output; + } +`; diff --git a/src/extras/index.js b/src/extras/index.js index 284f2a7f5b4..4c0db9e1013 100644 --- a/src/extras/index.js +++ b/src/extras/index.js @@ -21,6 +21,9 @@ export { export { LINEWIDTH_SCREEN, LINEWIDTH_WORLD, WideLineRenderer } from './renderers/wide-line-renderer.js'; export { WireRenderer } from './renderers/wire-renderer.js'; +// GRID +export { Grid } from './grid/grid.js'; + // EXPORTERS export { UsdzExporter } from './exporters/usdz-exporter.js'; export { GltfExporter } from './exporters/gltf-exporter.js';