Files
speckle-server/packages/viewer/src/modules/batching/PrimitiveBatch.ts
T
Alexandru Popovici c962d2c6fa Screen Space Outlines Update (#4185)
* Color hash from object id attribute

* feat(viewer-lib): Implemented screen space outlines based on object id gradients. Still slightly WIP, but the concept seems to be working fine

* Added engine model for testing outlines update

* feat(viewer-lib): Updates to object-id based gradients
- Id gradients now are now binary. If they are greater than 0, they will get updated to 1. This fixes the issue where similar nearby objects that happen to have similar colors would exhibit a fainter gradient
- Fixed an issue with DepthNormalIdPass where linear depth was not being used
- Cleaned up the edge generator shader

* feat(viewer-viewer): Added instancing support for screen space outline from color id gradients
- Instances now generate a correct and unique color hash based off a batch index and gl_InstanceID when running WebGL 2.0. For WebGL1.0 it's currently not supported
- Removed some unneeded non-null assertion left over from a long time ago
- Extended three.js InstancedMesh in order to add a batch index variable to it. Nothing more

* feat(viewer-lib): Added support for WebGL1.0 for both instanced and non instanced rendering of the color id gradients
- Batcher now takes all the webgl caps instead of just mac vert uniforms and float textures
- The depth-normal-id pass shader now works on both WebGL 1.0 and 2.0.
- When running WebGL 1.0 we dynamically add a per instance attribute buffer with the object id in order to compute hash. If we're running WebGL 2.0 we do not do that and instead rely on the builtin gl_InstanceID so we conserve bandwidth
- Fixed small type issue with the edge generation shader when running WebGL 1.0

* feat(viewer-lib): Updated the edged and shaded view modes to use the depth-normal-id pass

* Restored to main version.
2025-03-17 11:07:37 +02:00

437 lines
14 KiB
TypeScript

import { Material, Object3D, BufferGeometry, BufferAttribute, Box3 } from 'three'
import { NodeRenderView } from '../../index.js'
import {
AllBatchUpdateRange,
type Batch,
type BatchUpdateRange,
GeometryType,
NoneBatchUpdateRange
} from './Batch.js'
import { type DrawGroup } from './Batch.js'
import Materials from '../materials/Materials.js'
import SpeckleStandardColoredMaterial from '../materials/SpeckleStandardColoredMaterial.js'
import SpecklePointColouredMaterial from '../materials/SpecklePointColouredMaterial.js'
export abstract class Primitive<
TGeometry extends BufferGeometry = BufferGeometry,
TMaterial extends Material | Material[] = Material | Material[]
> extends Object3D {
geometry!: TGeometry
material!: TMaterial
visible!: boolean
}
export abstract class PrimitiveBatch implements Batch {
public id: string
public subtreeId: string
public renderViews: NodeRenderView[]
public batchMaterial: Material
protected abstract primitive: Primitive
protected gradientIndexBuffer: BufferAttribute
protected needsShuffle: boolean = false
abstract get geometryType(): GeometryType
abstract get bounds(): Box3
abstract get minDrawCalls(): number
abstract get triCount(): number
abstract get pointCount(): number
abstract get lineCount(): number
public get materials(): Material[] {
return this.primitive.material as Material[]
}
public get groups(): DrawGroup[] {
/** We always write to geomtry.groups via the set accessor
* which takes a DrawGroup[], so geometry.groups will always
* be an array of DrawGroup.
* Not to mention that **all our draw groupd are DrawGroup because
* they always have a materialIndex defined** by design and convention!!!
*/
return this.primitive.geometry.groups as DrawGroup[]
}
public set groups(value: DrawGroup[]) {
this.primitive.geometry.groups = value
}
public get renderObject(): Object3D {
return this.primitive
}
public get drawCalls(): number {
return this.groups.length
}
public get vertCount(): number {
return this.primitive.geometry.attributes.position.count
}
public getCount(): number {
return this.primitive.geometry.index?.count || 0
}
public setBatchMaterial(material: Material): void {
this.batchMaterial = material
}
public onUpdate() {
if (this.needsShuffle) {
this.shuffleDrawGroups()
this.needsShuffle = false
}
}
public setVisibleRange(ranges: BatchUpdateRange[]) {
/** Entire batch needs to NOT be drawn */
if (ranges.length === 1 && ranges[0] === NoneBatchUpdateRange) {
this.primitive.geometry.setDrawRange(0, 0)
/** We unset the 'visible' flag, otherwise three.js will still run pointless buffer binding commands*/
this.primitive.visible = false
return
}
/** Entire batch needs to BE drawn */
if (ranges.length === 1 && ranges[0] === AllBatchUpdateRange) {
this.primitive.geometry.setDrawRange(0, this.getCount())
this.primitive.visible = true
return
}
/** Parts of the batch need to be visible. We get the min/max offset and total count */
let minOffset = Infinity
let maxOffset = 0
ranges.forEach((range) => {
minOffset = Math.min(minOffset, range.offset)
maxOffset = Math.max(maxOffset, range.offset)
})
const offset = ranges.find((val) => val.offset === maxOffset)
this.primitive.geometry.setDrawRange(
minOffset,
maxOffset - minOffset + (offset ? offset.count : 0)
)
this.primitive.visible = true
}
public getVisibleRange(): BatchUpdateRange {
/** Entire batch is visible */
if (this.groups.length === 1 && this.primitive.visible) return AllBatchUpdateRange
/** Entire batch is hidden */
if (!this.primitive.visible) return NoneBatchUpdateRange
/** Parts of the batch are visible */
return {
offset: this.primitive.geometry.drawRange.start,
count: this.primitive.geometry.drawRange.count
}
}
public getOpaque(): BatchUpdateRange {
/** If there is any transparent or hidden group return the update range up to it's offset */
const transparentOrHiddenGroup = this.groups.find((value) => {
if (value.materialIndex === undefined) return false
return (
Materials.isTransparent(this.materials[value.materialIndex]) ||
this.materials[value.materialIndex].visible === false
)
})
if (transparentOrHiddenGroup) {
return {
offset: 0,
count: transparentOrHiddenGroup.start
}
}
/** Entire batch is opaque */
return AllBatchUpdateRange
}
public getDepth(): BatchUpdateRange {
/** If there is any transparent or hidden group return the update range up to it's offset */
const transparentOrHiddenGroup = this.groups.find((value) => {
if (value.materialIndex === undefined) return false
return (
Materials.isTransparent(this.materials[value.materialIndex]) ||
this.materials[value.materialIndex].visible === false ||
this.materials[value.materialIndex].colorWrite === false
)
})
if (transparentOrHiddenGroup) {
return {
offset: 0,
count: transparentOrHiddenGroup.start
}
}
/** Entire batch is opaque */
return AllBatchUpdateRange
}
public getTransparent(): BatchUpdateRange {
/** Look for a transparent group */
const transparentGroup = this.groups.find((value) => {
if (value.materialIndex === undefined) return false
return Materials.isTransparent(this.materials[value.materialIndex])
})
/** Look for a hidden group */
const hiddenGroup = this.groups.find((value) => {
if (value.materialIndex === undefined) return false
return this.materials[value.materialIndex].visible === false
})
/** If there is a transparent group return it's range */
if (transparentGroup) {
return {
offset: transparentGroup.start,
count:
hiddenGroup !== undefined
? hiddenGroup.start
: this.getCount() - transparentGroup.start
}
}
/** Entire batch is not transparent */
return NoneBatchUpdateRange
}
public getStencil(): BatchUpdateRange {
/** If there is a single group and it's material writes to stencil, return all */
if (this.groups.length === 1) {
if (this.materials[0].stencilWrite === true) return AllBatchUpdateRange
}
const stencilGroup = this.groups.find((value) => {
if (value.materialIndex === undefined) return false
return this.materials[value.materialIndex].stencilWrite === true
})
if (stencilGroup) {
return {
offset: stencilGroup.start,
count: stencilGroup.count
}
}
/** No stencil group */
return NoneBatchUpdateRange
}
public setBatchBuffers(ranges: BatchUpdateRange[]): void {
let minGradientIndex = Infinity
let maxGradientIndex = 0
for (let k = 0; k < ranges.length; k++) {
const range = ranges[k]
if (range.materialOptions) {
if (
range.materialOptions.rampIndex !== undefined &&
range.materialOptions.rampWidth !== undefined
) {
const start = ranges[k].offset
const len = ranges[k].offset + ranges[k].count
/** The ramp indices specify the *begining* of each ramp color. When sampling with Nearest filter (since we don't want filtering)
* we'll always be sampling right at the edge between texels. Most GPUs will sample consistently, but some won't and we end up with
* a ton of artifacts. To avoid this, we are shifting the sampling indices so they're right on the center of each texel, so no inconsistent
* sampling can occur.
*/
const shiftedIndex =
range.materialOptions.rampIndex + 0.5 / range.materialOptions.rampWidth
const minMaxIndices = this.updateGradientIndexBufferData(
start,
range.count === Infinity
? this.primitive.geometry.attributes['gradientIndex'].array.length
: len,
shiftedIndex
)
minGradientIndex = Math.min(minGradientIndex, minMaxIndices.minIndex)
maxGradientIndex = Math.max(maxGradientIndex, minMaxIndices.maxIndex)
}
/** We need to update the texture here, because each batch uses it's own clone for any material we use on it
* because otherwise three.js won't properly update our custom uniforms
*/
if (range.materialOptions.rampTexture !== undefined) {
if (
range.material instanceof SpeckleStandardColoredMaterial ||
range.material instanceof SpecklePointColouredMaterial
) {
range.material.setGradientTexture(range.materialOptions.rampTexture)
}
}
}
}
if (minGradientIndex < Infinity && maxGradientIndex > 0)
this.updateGradientIndexBuffer()
}
protected cleanMaterials() {
const materialsInUse = [
...Array.from(
new Set(
this.groups.map((value) => {
if (value.materialIndex === undefined) return undefined
return this.materials[value.materialIndex]
})
)
)
]
let k = 0
while (this.materials.length > materialsInUse.length) {
if (!materialsInUse.includes(this.materials[k])) {
this.materials.splice(k, 1)
this.groups.forEach((value: DrawGroup) => {
if (value.materialIndex === undefined) return
if (value.materialIndex > k) value.materialIndex--
})
k = 0
continue
}
k++
}
}
protected abstract getCurrentIndexBuffer(): BufferAttribute
protected abstract getNextIndexBuffer(): BufferAttribute
protected abstract shuffleMaterialOrder(a: DrawGroup, b: DrawGroup): number
private shuffleDrawGroups() {
const groups = this.groups.slice()
groups.sort(this.shuffleMaterialOrder.bind(this))
const materialOrder: Array<number> = []
groups.reduce((previousValue, currentValue) => {
if (currentValue.materialIndex !== undefined) {
if (previousValue.indexOf(currentValue.materialIndex) === -1) {
previousValue.push(currentValue.materialIndex)
}
}
return previousValue
}, materialOrder)
const grouped = []
for (let k = 0; k < materialOrder.length; k++) {
grouped.push(
groups.filter((val) => {
return val.materialIndex === materialOrder[k]
})
)
}
const sourceIBO: BufferAttribute = this.getCurrentIndexBuffer()
const targetIBO: BufferAttribute = this.getNextIndexBuffer()
const sourceIBOData: Uint16Array | Uint32Array = sourceIBO.array as
| Uint16Array
| Uint32Array
const targetIBOData: Uint16Array | Uint32Array = targetIBO.array as
| Uint16Array
| Uint32Array
const newGroups = []
const scratchRvs = this.renderViews.slice()
scratchRvs.sort((a, b) => {
return a.batchStart - b.batchStart
})
let targetIBOOffset = 0
for (let k = 0; k < grouped.length; k++) {
const materialGroup = grouped[k]
const materialGroupStart = targetIBOOffset
let materialGroupCount = 0
for (let i = 0; i < (materialGroup as []).length; i++) {
const start = materialGroup[i].start
const count = materialGroup[i].count
const subArray = sourceIBOData.subarray(start, start + count)
targetIBOData.set(subArray, targetIBOOffset)
let rvTrisCount = 0
for (let m = 0; m < scratchRvs.length; m++) {
if (
scratchRvs[m].batchStart >= start &&
scratchRvs[m].batchEnd <= start + count
) {
scratchRvs[m].setBatchData(
this.id,
targetIBOOffset + rvTrisCount,
scratchRvs[m].batchCount
)
rvTrisCount += scratchRvs[m].batchCount
scratchRvs.splice(m, 1)
m--
}
}
targetIBOOffset += count
materialGroupCount += count
}
newGroups.push({
offset: materialGroupStart,
count: materialGroupCount,
materialIndex: materialGroup[0].materialIndex
})
}
this.groups = []
for (let i = 0; i < newGroups.length; i++) {
this.primitive.geometry.addGroup(
newGroups[i].offset,
newGroups[i].count,
newGroups[i].materialIndex
)
}
this.primitive.geometry.setIndex(targetIBO)
/** Catering to typescript
* The line above literally makes sure the index is set. Absurd
*/
if (this.primitive.geometry.index) this.primitive.geometry.index.needsUpdate = true
const hiddenGroup = this.groups.find((value) => {
if (value.materialIndex === undefined) return false
return this.materials[value.materialIndex].visible === false
})
if (hiddenGroup) {
this.setVisibleRange([
{
offset: 0,
count: hiddenGroup.start
}
])
} else this.setVisibleRange([AllBatchUpdateRange])
// console.log('Final -> ', this.id, this.groups.slice())
}
protected abstract updateGradientIndexBufferData(
start: number,
end: number,
value: number
): { minIndex: number; maxIndex: number }
protected updateGradientIndexBuffer(rangeMin?: number, rangeMax?: number): void {
this.gradientIndexBuffer.updateRange = {
offset: rangeMin !== undefined ? rangeMin : 0,
count:
rangeMin !== undefined && rangeMax !== undefined ? rangeMax - rangeMin + 1 : -1
}
this.gradientIndexBuffer.needsUpdate = true
this.primitive.geometry.attributes['gradientIndex'].needsUpdate = true
}
public abstract setDrawRanges(ranges: BatchUpdateRange[]): void
public resetDrawRanges(): void {
this.primitive.visible = true
this.primitive.geometry.clearGroups()
this.primitive.geometry.addGroup(0, this.getCount(), 0)
this.primitive.geometry.setDrawRange(0, Infinity)
}
public abstract buildBatch(): Promise<void>
public abstract getRenderView(index: number): NodeRenderView | null
public abstract getMaterialAtIndex(index: number): Material | null
public getMaterial(rv: NodeRenderView): Material | null {
for (let k = 0; k < this.groups.length; k++) {
const group = this.groups[k]
if (rv.batchStart >= group.start && rv.batchEnd <= group.start + group.count) {
return this.materials[group.materialIndex]
}
}
return null
}
public purge(): void {
this.renderViews.length = 0
this.primitive.geometry.dispose()
this.batchMaterial.dispose()
}
}