background.ts 19 KB

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