Skip to content

Commit 104d08e

Browse files
feat: setup basic webgl renderer
1 parent d52c2ac commit 104d08e

File tree

12 files changed

+975
-8
lines changed

12 files changed

+975
-8
lines changed

web/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"@tanstack/query-core": "4.24.10",
6565
"@tanstack/react-query": "4.24.10",
6666
"@tanstack/react-query-devtools": "4.24.10",
67+
"@tweenjs/tween.js": "18.6.4",
6768
"animate.css": "4.1.1",
6869
"autoprefixer": "10.4.13",
6970
"axios": "1.3.4",
@@ -106,6 +107,7 @@
106107
"react-select": "5.7.0",
107108
"react-share": "4.4.1",
108109
"react-toggle": "4.1.3",
110+
"react-use": "17.4.0",
109111
"reactstrap": "8.10.1",
110112
"recharts": "2.4.3",
111113
"recoil": "0.7.6",
@@ -115,6 +117,9 @@
115117
"route-parser": "0.0.5",
116118
"serialize-javascript": "6.0.1",
117119
"styled-components": "5.3.6",
120+
"three": "0.150.0",
121+
"three-spritetext": "1.7.1",
122+
"three-stdlib": "2.21.8",
118123
"transliteration": "2.3.5",
119124
"typeface-droid-sans-mono": "0.0.44",
120125
"typeface-open-sans": "1.1.13",
@@ -155,6 +160,7 @@
155160
"@types/route-parser": "0.1.4",
156161
"@types/serialize-javascript": "5.0.2",
157162
"@types/styled-components": "5.1.26",
163+
"@types/three": "0.149.0",
158164
"@types/url-join": "4.0.1",
159165
"@types/use-sync-external-store": "0.0.3",
160166
"@typescript-eslint/eslint-plugin": "5.53.0",

web/src/components/Home/HomePage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import React from 'react'
22
import styled from 'styled-components'
3-
import { PROJECT_NAME } from 'src/constants'
43
import { PageContainerHorizontal } from 'src/components/Layout/PageContainer'
4+
import Viewer from 'src/components/Viewer/Viewer'
55

66
export function HomePage() {
77
return (
88
<PageContainerHorizontal>
99
<MainContent>
1010
<MainContentInner>
11-
<h2>{PROJECT_NAME}</h2>
11+
<Viewer />
1212
</MainContentInner>
1313
</MainContent>
1414
</PageContainerHorizontal>
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import {
2+
AxesHelper,
3+
DoubleSide,
4+
Mesh,
5+
MeshBasicMaterial,
6+
PerspectiveCamera,
7+
Scene,
8+
TorusGeometry,
9+
WebGLRenderer,
10+
} from 'three'
11+
import { Easing, Tween } from '@tweenjs/tween.js'
12+
import { OrbitControls } from 'three-stdlib'
13+
import type { RenderSettings } from 'src/components/Viewer/Scene'
14+
import type { UseViewportResult } from 'src/hooks/useViewport'
15+
import { CameraPreset } from 'src/state/camera.state'
16+
import { FrameCounter, RenderStats } from './gl/FrameCounter'
17+
import { makeCoordinatePlanes } from './gl/makePlane'
18+
import { UserInput } from './gl/UserInput'
19+
20+
const CAMERA_POS_Z = 300
21+
22+
export async function initGeometry(mesh?: Mesh) {
23+
const geometry = new TorusGeometry(10, 3, 16, 100)
24+
25+
const material = new MeshBasicMaterial({ color: 'lime', vertexColors: false, side: DoubleSide })
26+
if (!mesh) {
27+
mesh = new Mesh(geometry, material) // eslint-disable-line no-param-reassign
28+
}
29+
30+
// NOTE: Apply default mesh rotation, to make sure the default view is along negative Z axis.
31+
// NOTE: This prevents weird OrbitControls behavior.
32+
mesh.rotation.x = -Math.PI / 2
33+
34+
return mesh
35+
}
36+
37+
export function getViewportSize(canvas: HTMLCanvasElement) {
38+
if (!canvas.parentElement) {
39+
throw new Error('Canvas element parent is invalid')
40+
}
41+
const rect = canvas.parentElement?.getBoundingClientRect()
42+
const { width, height } = rect
43+
return { width, height }
44+
}
45+
46+
export class Renderer {
47+
private readonly renderer: WebGLRenderer
48+
private readonly scene: Scene
49+
private readonly camera: PerspectiveCamera
50+
private readonly controls: OrbitControls
51+
private readonly input: UserInput
52+
53+
private cameraPresetTarget: CameraPreset | undefined
54+
private cameraTween: Tween<CameraPreset> | undefined
55+
56+
private mesh: Mesh | undefined
57+
private material: MeshBasicMaterial | undefined
58+
59+
private rafHandle: number | undefined
60+
private shouldRender = true
61+
62+
public readonly frameCounter: FrameCounter
63+
64+
public constructor(
65+
canvas: HTMLCanvasElement,
66+
renderSettings: RenderSettings,
67+
cameraPreset: CameraPreset,
68+
color: string,
69+
setRenderStats: (stats: RenderStats) => void,
70+
) {
71+
if (!canvas) {
72+
throw new Error('Canvas element is invalid')
73+
}
74+
75+
const { width, height } = getViewportSize(canvas)
76+
const aspect = width / height
77+
78+
this.renderer = new WebGLRenderer({
79+
canvas,
80+
antialias: true,
81+
precision: 'highp',
82+
powerPreference: 'high-performance',
83+
})
84+
this.renderer.setClearColor(renderSettings.clearColor)
85+
this.renderer.setPixelRatio(window.devicePixelRatio)
86+
this.renderer.setSize(width, height)
87+
88+
this.scene = new Scene()
89+
90+
this.camera = new PerspectiveCamera(75, aspect, 0.1, 100_000)
91+
this.camera.position.z = CAMERA_POS_Z
92+
this.controls = new OrbitControls(this.camera, canvas)
93+
this.controls.minDistance = 10
94+
this.controls.maxDistance = 500
95+
this.controls.zoomSpeed = 2
96+
this.onCameraPreset(cameraPreset, false)
97+
98+
this.frameCounter = new FrameCounter(setRenderStats)
99+
100+
this.input = new UserInput(canvas)
101+
102+
void this.init() // eslint-disable-line no-void
103+
}
104+
105+
public async init() {
106+
this.mesh = await initGeometry(this.mesh)
107+
this.mesh.geometry.computeBoundingSphere()
108+
this.scene.add(this.mesh)
109+
110+
const axesHelper = new AxesHelper(1000)
111+
this.scene.add(axesHelper)
112+
113+
const coordPlanes = makeCoordinatePlanes()
114+
this.scene.add(...coordPlanes)
115+
}
116+
117+
public destroy() {
118+
if (this.rafHandle) {
119+
cancelAnimationFrame(this.rafHandle)
120+
}
121+
122+
this.mesh?.geometry.dispose()
123+
this.material?.dispose()
124+
this.camera.clear()
125+
this.controls.dispose()
126+
this.scene.clear()
127+
this.scene.removeFromParent()
128+
}
129+
130+
public onResize({ width, height }: UseViewportResult) {
131+
if (width === 0 || height === 0) {
132+
return
133+
}
134+
this.camera.aspect = width / height
135+
this.camera.updateProjectionMatrix()
136+
this.renderer.setSize(width, height)
137+
}
138+
139+
public onCameraPreset(cameraPreset: CameraPreset, animated = true) {
140+
if (animated) {
141+
this.cameraTween = new Tween<CameraPreset>({
142+
polar: this.controls.getPolarAngle(),
143+
azimuthal: this.controls.getAzimuthalAngle(),
144+
})
145+
.to(cameraPreset, 250)
146+
.easing(Easing.Quadratic.Out)
147+
.onComplete((_) => {
148+
this.cameraPresetTarget = undefined
149+
})
150+
.onUpdate((cameraPreset) => {
151+
this.cameraPresetTarget = cameraPreset
152+
})
153+
.start()
154+
} else {
155+
const { polar, azimuthal } = cameraPreset
156+
this.controls.setPolarAngle(polar)
157+
this.controls.setAzimuthalAngle(azimuthal)
158+
}
159+
}
160+
161+
public startRenderLoop(_time: number) {
162+
const { deltaMs } = this.frameCounter.update()
163+
this.cameraTween?.update()
164+
165+
this.update(deltaMs)
166+
this.render(deltaMs)
167+
168+
this.rafHandle = requestAnimationFrame((time) => this.startRenderLoop(time))
169+
}
170+
171+
public update(_deltaMs: number) {
172+
if (this.cameraPresetTarget) {
173+
this.controls.setPolarAngle(this.cameraPresetTarget.polar)
174+
this.controls.setAzimuthalAngle(this.cameraPresetTarget.azimuthal)
175+
}
176+
177+
this.controls.update()
178+
this.camera.updateProjectionMatrix()
179+
180+
if (this.input.isCtrlDown) {
181+
this.controls.enableRotate = false
182+
this.controls.enablePan = false
183+
} else {
184+
this.controls.enableRotate = true
185+
this.controls.enablePan = true
186+
}
187+
}
188+
189+
public render(_deltaMs: number) {
190+
if (this.shouldRender) {
191+
this.frameCounter.frame()
192+
this.renderer.clear()
193+
this.renderer.render(this.scene, this.camera)
194+
}
195+
}
196+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useCallback, useEffect, useRef, useState } from 'react'
2+
import { useRecoilValue } from 'recoil'
3+
import styled from 'styled-components'
4+
import { cameraAtom } from 'src/state/camera.state'
5+
import { useViewport } from 'src/hooks/useViewport'
6+
import { RenderStats } from './gl/FrameCounter'
7+
import { Renderer } from './Renderer'
8+
9+
export const Viewport = styled.div`
10+
width: 100%;
11+
height: 100%;
12+
overflow: hidden;
13+
`
14+
15+
export const Canvas = styled.canvas`
16+
width: 100%;
17+
height: 100%;
18+
overflow: hidden;
19+
`
20+
21+
export interface RenderSettings {
22+
clearColor: string
23+
wireframe: boolean
24+
}
25+
26+
const renderSettingsDefaults: RenderSettings = {
27+
clearColor: '#111',
28+
wireframe: false,
29+
}
30+
31+
export interface SceneProps {
32+
name: string
33+
color: string
34+
}
35+
36+
export default function Scene({ name, color }: SceneProps) {
37+
const [renderer, setRenderer] = useState<Renderer | null>(null)
38+
const { cameraPreset, cameraPresets } = useRecoilValue(cameraAtom)
39+
40+
const [renderSettings] = useState(renderSettingsDefaults)
41+
const viewportElem = useRef<HTMLDivElement>(null)
42+
const viewport = useViewport({ targetRef: viewportElem })
43+
44+
const [, setStats] = useState<RenderStats>({ fps: 0, frameTime: 0, ups: 0, updateTime: 0 })
45+
46+
const onCanvasRef = useCallback(
47+
(canvasNode: HTMLCanvasElement) => {
48+
if (renderer && !canvasNode) {
49+
renderer?.destroy()
50+
setRenderer(null)
51+
}
52+
53+
if (renderer || !canvasNode) {
54+
return
55+
}
56+
57+
const cameraPreset = cameraPresets.Lateral
58+
const newRendererGeo = new Renderer(canvasNode, renderSettings, cameraPreset, color, setStats)
59+
60+
setRenderer(newRendererGeo)
61+
newRendererGeo.startRenderLoop(0)
62+
},
63+
[renderer], // eslint-disable-line react-hooks/exhaustive-deps
64+
)
65+
66+
/** Handle viewport resize */
67+
useEffect(() => renderer?.onResize(viewport), [renderer, viewport])
68+
69+
useEffect(() => renderer?.onCameraPreset(cameraPreset), [renderer, cameraPreset])
70+
71+
return (
72+
<Viewport ref={viewportElem}>
73+
<Canvas ref={onCanvasRef} />
74+
</Viewport>
75+
)
76+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react'
2+
import styled from 'styled-components'
3+
import Scene from 'src/components/Viewer/Scene'
4+
5+
export const Container = styled.div`
6+
margin: 0;
7+
padding: 0;
8+
height: 100%;
9+
overflow: auto;
10+
`
11+
12+
export const ViewerWrapper = styled.div`
13+
display: flex;
14+
flex-direction: row;
15+
flex-wrap: wrap;
16+
width: 100%;
17+
height: 100%;
18+
`
19+
20+
export const Window = styled.div`
21+
flex: 1 0 50%;
22+
height: 100%;
23+
overflow: auto;
24+
`
25+
26+
export default function Viewer() {
27+
return (
28+
<Container>
29+
<ViewerWrapper>
30+
<Window>
31+
<Scene name="1" color="#000000" />
32+
</Window>
33+
</ViewerWrapper>
34+
</Container>
35+
)
36+
}

0 commit comments

Comments
 (0)