background.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. /**
  2. * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author Alexander Rose <alexander.rose@weirdbyte.de>
  5. */
  6. import { QuadPositions, } from '../../mol-gl/compute/util';
  7. import { ComputeRenderable, createComputeRenderable } from '../../mol-gl/renderable';
  8. import { AttributeSpec, DefineSpec, TextureSpec, UniformSpec, Values, ValueSpec } from '../../mol-gl/renderable/schema';
  9. import { ShaderCode } from '../../mol-gl/shader-code';
  10. import { background_frag } from '../../mol-gl/shader/background.frag';
  11. import { background_vert } from '../../mol-gl/shader/background.vert';
  12. import { WebGLContext } from '../../mol-gl/webgl/context';
  13. import { createComputeRenderItem } from '../../mol-gl/webgl/render-item';
  14. import { createNullTexture, CubeFaces, Texture } from '../../mol-gl/webgl/texture';
  15. import { Mat4 } from '../../mol-math/linear-algebra/3d/mat4';
  16. import { ValueCell } from '../../mol-util/value-cell';
  17. import { ParamDefinition as PD } from '../../mol-util/param-definition';
  18. import { isTimingMode } from '../../mol-util/debug';
  19. import { Camera, ICamera } from '../camera';
  20. import { Vec3 } from '../../mol-math/linear-algebra/3d/vec3';
  21. import { Vec2 } from '../../mol-math/linear-algebra/3d/vec2';
  22. import { Color } from '../../mol-util/color';
  23. import { Asset, AssetManager } from '../../mol-util/assets';
  24. import { Vec4 } from '../../mol-math/linear-algebra/3d/vec4';
  25. const SharedParams = {
  26. opacity: PD.Numeric(1, { min: 0.0, max: 1.0, step: 0.01 }),
  27. saturation: PD.Numeric(0, { min: -1, max: 1, step: 0.01 }),
  28. lightness: PD.Numeric(0, { min: -1, max: 1, step: 0.01 }),
  29. };
  30. const SkyboxParams = {
  31. faces: PD.MappedStatic('urls', {
  32. urls: PD.Group({
  33. nx: PD.Text('', { label: 'Negative X' }),
  34. ny: PD.Text('', { label: 'Negative Y' }),
  35. nz: PD.Text('', { label: 'Negative Z' }),
  36. px: PD.Text('', { label: 'Positive X' }),
  37. py: PD.Text('', { label: 'Positive Y' }),
  38. pz: PD.Text('', { label: 'Positive Z' }),
  39. }, { isExpanded: true, label: 'URLs' }),
  40. files: PD.Group({
  41. nx: PD.File({ label: 'Negative X', accept: 'image/*' }),
  42. ny: PD.File({ label: 'Negative Y', accept: 'image/*' }),
  43. nz: PD.File({ label: 'Negative Z', accept: 'image/*' }),
  44. px: PD.File({ label: 'Positive X', accept: 'image/*' }),
  45. py: PD.File({ label: 'Positive Y', accept: 'image/*' }),
  46. pz: PD.File({ label: 'Positive Z', accept: 'image/*' }),
  47. }, { isExpanded: true, label: 'Files' }),
  48. }),
  49. ...SharedParams,
  50. };
  51. type SkyboxProps = PD.Values<typeof SkyboxParams>
  52. const ImageParams = {
  53. source: PD.MappedStatic('url', {
  54. url: PD.Text(''),
  55. file: PD.File({ accept: 'image/*' }),
  56. }),
  57. ...SharedParams,
  58. coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
  59. };
  60. type ImageProps = PD.Values<typeof ImageParams>
  61. const HorizontalGradientParams = {
  62. topColor: PD.Color(Color(0xDDDDDD)),
  63. bottomColor: PD.Color(Color(0xEEEEEE)),
  64. ratio: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }),
  65. coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
  66. };
  67. const RadialGradientParams = {
  68. centerColor: PD.Color(Color(0xDDDDDD)),
  69. edgeColor: PD.Color(Color(0xEEEEEE)),
  70. ratio: PD.Numeric(0.5, { min: 0.0, max: 1.0, step: 0.01 }),
  71. coverage: PD.Select('viewport', PD.arrayToOptions(['viewport', 'canvas'])),
  72. };
  73. export const BackgroundParams = {
  74. variant: PD.MappedStatic('off', {
  75. off: PD.EmptyGroup(),
  76. skybox: PD.Group(SkyboxParams, { isExpanded: true }),
  77. image: PD.Group(ImageParams, { isExpanded: true }),
  78. horizontalGradient: PD.Group(HorizontalGradientParams, { isExpanded: true }),
  79. radialGradient: PD.Group(RadialGradientParams, { isExpanded: true }),
  80. }, { label: 'Environment' }),
  81. };
  82. export type BackgroundProps = PD.Values<typeof BackgroundParams>
  83. export class BackgroundPass {
  84. private renderable: BackgroundRenderable;
  85. private skybox: {
  86. texture: Texture
  87. props: SkyboxProps
  88. assets: Asset[]
  89. loaded: boolean
  90. } | undefined;
  91. private image: {
  92. texture: Texture
  93. props: ImageProps
  94. asset: Asset
  95. loaded: boolean
  96. } | undefined;
  97. private readonly camera = new Camera();
  98. private readonly target = Vec3();
  99. private readonly position = Vec3();
  100. private readonly dir = Vec3();
  101. readonly texture: Texture;
  102. constructor(private readonly webgl: WebGLContext, private readonly assetManager: AssetManager, width: number, height: number) {
  103. this.renderable = getBackgroundRenderable(webgl, width, height);
  104. }
  105. setSize(width: number, height: number) {
  106. const [w, h] = this.renderable.values.uTexSize.ref.value;
  107. if (width !== w || height !== h) {
  108. ValueCell.update(this.renderable.values.uTexSize, Vec2.set(this.renderable.values.uTexSize.ref.value, width, height));
  109. }
  110. }
  111. private clearSkybox() {
  112. if (this.skybox !== undefined) {
  113. this.skybox.texture.destroy();
  114. this.skybox.assets.forEach(a => this.assetManager.release(a));
  115. this.skybox = undefined;
  116. }
  117. }
  118. private updateSkybox(camera: ICamera, props: SkyboxProps, onload?: (changed: boolean) => void) {
  119. const tf = this.skybox?.props.faces;
  120. const f = props.faces.params;
  121. if (!f.nx || !f.ny || !f.nz || !f.px || !f.py || !f.pz) {
  122. this.clearSkybox();
  123. if (onload) onload(false);
  124. return;
  125. }
  126. if (!this.skybox || !tf || !areSkyboxTexturePropsEqual(props.faces, this.skybox.props.faces)) {
  127. this.clearSkybox();
  128. const { texture, assets } = getSkyboxTexture(this.webgl, this.assetManager, props.faces, () => {
  129. if (this.skybox) this.skybox.loaded = true;
  130. if (onload) onload(true);
  131. });
  132. this.skybox = { texture, props: { ...props }, assets, loaded: false };
  133. ValueCell.update(this.renderable.values.tSkybox, texture);
  134. this.renderable.update();
  135. } else {
  136. if (onload) onload(false);
  137. }
  138. if (!this.skybox) return;
  139. let cam = camera;
  140. if (camera.state.mode === 'orthographic') {
  141. this.camera.setState({ ...camera.state, mode: 'perspective' });
  142. this.camera.update();
  143. cam = this.camera;
  144. }
  145. const m = this.renderable.values.uViewDirectionProjectionInverse.ref.value;
  146. Vec3.sub(this.dir, cam.state.position, cam.state.target);
  147. Vec3.setMagnitude(this.dir, this.dir, 0.1);
  148. Vec3.copy(this.position, this.dir);
  149. Mat4.lookAt(m, this.position, this.target, cam.state.up);
  150. Mat4.mul(m, cam.projection, m);
  151. Mat4.invert(m, m);
  152. ValueCell.update(this.renderable.values.uViewDirectionProjectionInverse, m);
  153. ValueCell.updateIfChanged(this.renderable.values.uOpacity, props.opacity);
  154. ValueCell.updateIfChanged(this.renderable.values.uSaturation, props.saturation);
  155. ValueCell.updateIfChanged(this.renderable.values.uLightness, props.lightness);
  156. ValueCell.updateIfChanged(this.renderable.values.dVariant, 'skybox');
  157. this.renderable.update();
  158. }
  159. private clearImage() {
  160. if (this.image !== undefined) {
  161. this.image.texture.destroy();
  162. this.assetManager.release(this.image.asset);
  163. this.image = undefined;
  164. }
  165. }
  166. private updateImage(props: ImageProps, onload?: (loaded: boolean) => void) {
  167. if (!props.source.params) {
  168. this.clearImage();
  169. if (onload) onload(false);
  170. return;
  171. }
  172. if (!this.image || !this.image.props.source.params || !areImageTexturePropsEqual(props.source, this.image.props.source)) {
  173. this.clearImage();
  174. const { texture, asset } = getImageTexture(this.webgl, this.assetManager, props.source, () => {
  175. if (this.image) this.image.loaded = true;
  176. if (onload) onload(true);
  177. });
  178. this.image = { texture, props: { ...props }, asset, loaded: false };
  179. ValueCell.update(this.renderable.values.tImage, texture);
  180. this.renderable.update();
  181. } else {
  182. if (onload) onload(false);
  183. }
  184. if (!this.image) return;
  185. ValueCell.updateIfChanged(this.renderable.values.uOpacity, props.opacity);
  186. ValueCell.updateIfChanged(this.renderable.values.uSaturation, props.saturation);
  187. ValueCell.updateIfChanged(this.renderable.values.uLightness, props.lightness);
  188. ValueCell.updateIfChanged(this.renderable.values.uViewportAdjusted, props.coverage === 'viewport' ? true : false);
  189. ValueCell.updateIfChanged(this.renderable.values.dVariant, 'image');
  190. this.renderable.update();
  191. }
  192. private updateImageScaling() {
  193. const v = this.renderable.values;
  194. const [w, h] = v.uTexSize.ref.value;
  195. const iw = this.image?.texture.getWidth() || 0;
  196. const ih = this.image?.texture.getHeight() || 0;
  197. const r = w / h;
  198. const ir = iw / ih;
  199. // responsive scaling with offset
  200. if (r < ir) {
  201. ValueCell.update(v.uImageScale, Vec2.set(v.uImageScale.ref.value, iw * h / ih, h));
  202. } else {
  203. ValueCell.update(v.uImageScale, Vec2.set(v.uImageScale.ref.value, w, ih * w / iw));
  204. }
  205. const [rw, rh] = v.uImageScale.ref.value;
  206. const sr = rw / rh;
  207. if (sr > r) {
  208. ValueCell.update(v.uImageOffset, Vec2.set(v.uImageOffset.ref.value, (1 - r / sr) / 2, 0));
  209. } else {
  210. ValueCell.update(v.uImageOffset, Vec2.set(v.uImageOffset.ref.value, 0, (1 - sr / r) / 2));
  211. }
  212. }
  213. private updateGradient(colorA: Color, colorB: Color, ratio: number, variant: 'horizontalGradient' | 'radialGradient', viewportAdjusted: boolean) {
  214. ValueCell.update(this.renderable.values.uGradientColorA, Color.toVec3Normalized(this.renderable.values.uGradientColorA.ref.value, colorA));
  215. ValueCell.update(this.renderable.values.uGradientColorB, Color.toVec3Normalized(this.renderable.values.uGradientColorB.ref.value, colorB));
  216. ValueCell.updateIfChanged(this.renderable.values.uGradientRatio, ratio);
  217. ValueCell.updateIfChanged(this.renderable.values.uViewportAdjusted, viewportAdjusted);
  218. ValueCell.updateIfChanged(this.renderable.values.dVariant, variant);
  219. this.renderable.update();
  220. }
  221. update(camera: ICamera, props: BackgroundProps, onload?: (changed: boolean) => void) {
  222. if (props.variant.name === 'off') {
  223. this.clearSkybox();
  224. this.clearImage();
  225. if (onload) onload(false);
  226. return;
  227. } else if (props.variant.name === 'skybox') {
  228. this.clearImage();
  229. this.updateSkybox(camera, props.variant.params, onload);
  230. } else if (props.variant.name === 'image') {
  231. this.clearSkybox();
  232. this.updateImage(props.variant.params, onload);
  233. } else if (props.variant.name === 'horizontalGradient') {
  234. this.clearSkybox();
  235. this.clearImage();
  236. this.updateGradient(props.variant.params.topColor, props.variant.params.bottomColor, props.variant.params.ratio, props.variant.name, props.variant.params.coverage === 'viewport' ? true : false);
  237. if (onload) onload(false);
  238. } else if (props.variant.name === 'radialGradient') {
  239. this.clearSkybox();
  240. this.clearImage();
  241. this.updateGradient(props.variant.params.centerColor, props.variant.params.edgeColor, props.variant.params.ratio, props.variant.name, props.variant.params.coverage === 'viewport' ? true : false);
  242. if (onload) onload(false);
  243. }
  244. const { x, y, width, height } = camera.viewport;
  245. ValueCell.update(this.renderable.values.uViewport, Vec4.set(this.renderable.values.uViewport.ref.value, x, y, width, height));
  246. }
  247. isEnabled(props: BackgroundProps) {
  248. return !!(
  249. (this.skybox && this.skybox.loaded) ||
  250. (this.image && this.image.loaded) ||
  251. props.variant.name === 'horizontalGradient' ||
  252. props.variant.name === 'radialGradient'
  253. );
  254. }
  255. private isReady() {
  256. return !!(
  257. (this.skybox && this.skybox.loaded) ||
  258. (this.image && this.image.loaded) ||
  259. this.renderable.values.dVariant.ref.value === 'horizontalGradient' ||
  260. this.renderable.values.dVariant.ref.value === 'radialGradient'
  261. );
  262. }
  263. render() {
  264. if (!this.isReady()) return;
  265. if (this.renderable.values.dVariant.ref.value === 'image') {
  266. this.updateImageScaling();
  267. }
  268. if (isTimingMode) this.webgl.timer.mark('BackgroundPass.render');
  269. this.renderable.render();
  270. if (isTimingMode) this.webgl.timer.markEnd('BackgroundPass.render');
  271. }
  272. dispose() {
  273. this.clearSkybox();
  274. this.clearImage();
  275. }
  276. }
  277. //
  278. const SkyboxName = 'background-skybox';
  279. type CubeAssets = { [k in keyof CubeFaces]: Asset };
  280. function getCubeAssets(assetManager: AssetManager, faces: SkyboxProps['faces']): CubeAssets {
  281. if (faces.name === 'urls') {
  282. return {
  283. nx: Asset.getUrlAsset(assetManager, faces.params.nx),
  284. ny: Asset.getUrlAsset(assetManager, faces.params.ny),
  285. nz: Asset.getUrlAsset(assetManager, faces.params.nz),
  286. px: Asset.getUrlAsset(assetManager, faces.params.px),
  287. py: Asset.getUrlAsset(assetManager, faces.params.py),
  288. pz: Asset.getUrlAsset(assetManager, faces.params.pz),
  289. };
  290. } else {
  291. return {
  292. nx: faces.params.nx!,
  293. ny: faces.params.ny!,
  294. nz: faces.params.nz!,
  295. px: faces.params.px!,
  296. py: faces.params.py!,
  297. pz: faces.params.pz!,
  298. };
  299. }
  300. }
  301. function getCubeFaces(assetManager: AssetManager, cubeAssets: CubeAssets): CubeFaces {
  302. const resolve = (asset: Asset) => {
  303. return assetManager.resolve(asset, 'binary').run().then(a => new Blob([a.data]));
  304. };
  305. return {
  306. nx: resolve(cubeAssets.nx),
  307. ny: resolve(cubeAssets.ny),
  308. nz: resolve(cubeAssets.nz),
  309. px: resolve(cubeAssets.px),
  310. py: resolve(cubeAssets.py),
  311. pz: resolve(cubeAssets.pz),
  312. };
  313. }
  314. function getSkyboxHash(faces: SkyboxProps['faces']) {
  315. if (faces.name === 'urls') {
  316. return `${SkyboxName}_${faces.params.nx}|${faces.params.ny}|${faces.params.nz}|${faces.params.px}|${faces.params.py}|${faces.params.pz}`;
  317. } else {
  318. return `${SkyboxName}_${faces.params.nx?.id}|${faces.params.ny?.id}|${faces.params.nz?.id}|${faces.params.px?.id}|${faces.params.py?.id}|${faces.params.pz?.id}`;
  319. }
  320. }
  321. function areSkyboxTexturePropsEqual(facesA: SkyboxProps['faces'], facesB: SkyboxProps['faces']) {
  322. return getSkyboxHash(facesA) === getSkyboxHash(facesB);
  323. }
  324. function getSkyboxTexture(ctx: WebGLContext, assetManager: AssetManager, faces: SkyboxProps['faces'], onload?: () => void): { texture: Texture, assets: Asset[] } {
  325. const cubeAssets = getCubeAssets(assetManager, faces);
  326. const cubeFaces = getCubeFaces(assetManager, cubeAssets);
  327. const assets = [cubeAssets.nx, cubeAssets.ny, cubeAssets.nz, cubeAssets.px, cubeAssets.py, cubeAssets.pz];
  328. const texture = ctx.resources.cubeTexture(cubeFaces, false, onload);
  329. return { texture, assets };
  330. }
  331. //
  332. const ImageName = 'background-image';
  333. function getImageHash(source: ImageProps['source']) {
  334. if (source.name === 'url') {
  335. return `${ImageName}_${source.params}`;
  336. } else {
  337. return `${ImageName}_${source.params?.id}`;
  338. }
  339. }
  340. function areImageTexturePropsEqual(sourceA: ImageProps['source'], sourceB: ImageProps['source']) {
  341. return getImageHash(sourceA) === getImageHash(sourceB);
  342. }
  343. function getImageTexture(ctx: WebGLContext, assetManager: AssetManager, source: ImageProps['source'], onload?: () => void): { texture: Texture, asset: Asset } {
  344. const texture = ctx.resources.texture('image-uint8', 'rgba', 'ubyte', 'linear');
  345. const img = new Image();
  346. img.onload = () => {
  347. texture.load(img);
  348. onload?.();
  349. };
  350. const asset = source.name === 'url'
  351. ? Asset.getUrlAsset(assetManager, source.params)
  352. : source.params!;
  353. assetManager.resolve(asset, 'binary').run().then(a => {
  354. const blob = new Blob([a.data]);
  355. img.src = URL.createObjectURL(blob);
  356. });
  357. return { texture, asset };
  358. }
  359. //
  360. const BackgroundSchema = {
  361. drawCount: ValueSpec('number'),
  362. instanceCount: ValueSpec('number'),
  363. aPosition: AttributeSpec('float32', 2, 0),
  364. tSkybox: TextureSpec('texture', 'rgba', 'ubyte', 'linear'),
  365. tImage: TextureSpec('texture', 'rgba', 'ubyte', 'linear'),
  366. uImageScale: UniformSpec('v2'),
  367. uImageOffset: UniformSpec('v2'),
  368. uTexSize: UniformSpec('v2'),
  369. uViewport: UniformSpec('v4'),
  370. uViewportAdjusted: UniformSpec('b'),
  371. uViewDirectionProjectionInverse: UniformSpec('m4'),
  372. uGradientColorA: UniformSpec('v3'),
  373. uGradientColorB: UniformSpec('v3'),
  374. uGradientRatio: UniformSpec('f'),
  375. uOpacity: UniformSpec('f'),
  376. uSaturation: UniformSpec('f'),
  377. uLightness: UniformSpec('f'),
  378. dVariant: DefineSpec('string', ['skybox', 'image', 'verticalGradient', 'horizontalGradient', 'radialGradient']),
  379. };
  380. const SkyboxShaderCode = ShaderCode('background', background_vert, background_frag);
  381. type BackgroundRenderable = ComputeRenderable<Values<typeof BackgroundSchema>>
  382. function getBackgroundRenderable(ctx: WebGLContext, width: number, height: number): BackgroundRenderable {
  383. const values: Values<typeof BackgroundSchema> = {
  384. drawCount: ValueCell.create(6),
  385. instanceCount: ValueCell.create(1),
  386. aPosition: ValueCell.create(QuadPositions),
  387. tSkybox: ValueCell.create(createNullTexture()),
  388. tImage: ValueCell.create(createNullTexture()),
  389. uImageScale: ValueCell.create(Vec2()),
  390. uImageOffset: ValueCell.create(Vec2()),
  391. uTexSize: ValueCell.create(Vec2.create(width, height)),
  392. uViewport: ValueCell.create(Vec4()),
  393. uViewportAdjusted: ValueCell.create(true),
  394. uViewDirectionProjectionInverse: ValueCell.create(Mat4()),
  395. uGradientColorA: ValueCell.create(Vec3()),
  396. uGradientColorB: ValueCell.create(Vec3()),
  397. uGradientRatio: ValueCell.create(0.5),
  398. uOpacity: ValueCell.create(1),
  399. uSaturation: ValueCell.create(0),
  400. uLightness: ValueCell.create(0),
  401. dVariant: ValueCell.create('skybox'),
  402. };
  403. const schema = { ...BackgroundSchema };
  404. const renderItem = createComputeRenderItem(ctx, 'triangles', SkyboxShaderCode, schema, values);
  405. return createComputeRenderable(renderItem, values);
  406. }