material.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  1. 'use strict';
  2. const { join, sep, normalize } = require('path');
  3. module.paths.push(join(Editor.App.path, 'node_modules'));
  4. const { materialTechniquePolyfill } = require('../utils/material');
  5. const { setDisabled, setReadonly, setHidden, loopSetAssetDumpDataReadonly, injectionStyle } = require('../utils/prop');
  6. const { escape, isNil } = require('lodash');
  7. const effectGroupNameRE = /^db:\/\/(\w+)\//i; // match root DB name
  8. const effectDirRE = /^effects\//i;
  9. /**
  10. * @param {string} label
  11. */
  12. function formatOptionLabel(label) {
  13. // 1. remove group name if matched
  14. // 2. remove prefix 'effects'(after 'db://' prefix removed)
  15. // 3. escape label string because it will be used as html template string
  16. return escape(label.replace(effectGroupNameRE, '').replace(effectDirRE, ''));
  17. }
  18. /**
  19. *
  20. * @param {{name: string; uuid: string; assetPath: string}[]} effects
  21. * @returns html template
  22. */
  23. function renderGroupEffectOptions(effects) {
  24. // group effects by group name, and no longer rely on the ordering of the input `effects`.
  25. const groups = {};
  26. /**
  27. * ungrouped options. html template string.
  28. * @type {string[]}
  29. */
  30. const extras = [];
  31. for (const effect of effects) {
  32. const groupName = effectGroupNameRE.exec(effect.assetPath)?.[1] ?? '';
  33. if (groupName !== '') {
  34. let group = groups[groupName];
  35. // group not found yet, init it
  36. if (!Array.isArray(group)) {
  37. group = [];
  38. groups[groupName] = group;
  39. }
  40. const label = formatOptionLabel(effect.assetPath);
  41. group.push(`<option value="${effect.name}" data-uuid="${effect.uuid}">${label}</option>`);
  42. continue;
  43. }
  44. // no group name, add to extras and render as ungrouped(before grouped options)
  45. const label = formatOptionLabel(effect.name);
  46. extras.push(`<option value="${effect.name}" data-uuid="${effect.uuid}">${label}</option>`);
  47. }
  48. let htmlTemplate = '';
  49. for (const extra of extras) {
  50. htmlTemplate += extra;
  51. }
  52. for (const name in groups) {
  53. const options = groups[name];
  54. htmlTemplate += `<optgroup label="${name}">${options.join('')}</optgroup>`;
  55. }
  56. return htmlTemplate;
  57. }
  58. exports.style = `
  59. .invalid { display: none; text-align: center; margin-top: 8px; }
  60. .invalid[active] { display: block; }
  61. .invalid[active] ~ * { display: none; }
  62. :host > .header {
  63. padding-right: 4px;
  64. }
  65. :host > .default > .section {
  66. padding-right: 4px;
  67. }
  68. .custom[src] + .default { display: none; }
  69. ui-button.location { flex: none; margin-left: 4px; }
  70. `;
  71. exports.template = /* html */ `
  72. <div class="invalid">
  73. <ui-label value="i18n:ENGINE.assets.multipleWarning"></ui-label>
  74. </div>
  75. <header class="header">
  76. <ui-prop>
  77. <ui-label slot="label">Effect</ui-label>
  78. <ui-select class="effect" slot="content"></ui-select>
  79. <ui-button class="location" slot="content" tooltip="i18n:ENGINE.assets.locate_asset">
  80. <ui-icon value="location"></ui-icon>
  81. </ui-button>
  82. </ui-prop>
  83. <ui-prop>
  84. <ui-label slot="label">Technique</ui-label>
  85. <ui-select class="technique" slot="content"></ui-select>
  86. </ui-prop>
  87. </header>
  88. <ui-panel class="custom"></ui-panel>
  89. <div class="default">
  90. <section class="section">
  91. <ui-prop class="useInstancing" type="dump"></ui-prop>
  92. </section>
  93. <section class="material-dump"></section>
  94. </div>
  95. `;
  96. exports.$ = {
  97. invalid: '.invalid',
  98. header: '.header',
  99. effect: '.effect',
  100. location: '.location',
  101. technique: '.technique',
  102. useInstancing: '.useInstancing',
  103. materialDump: '.material-dump',
  104. custom: '.custom',
  105. };
  106. exports.methods = {
  107. record() {
  108. return JSON.stringify({
  109. material: this.material,
  110. cacheData: this.cacheData,
  111. });
  112. },
  113. async restore(record) {
  114. record = JSON.parse(record);
  115. if (!record || typeof record !== 'object' || !record.material) {
  116. return false;
  117. }
  118. this.material = record.material;
  119. this.cacheData = record.cacheData;
  120. await this.updateEffect();
  121. await this.updateInterface();
  122. await this.change();
  123. return true;
  124. },
  125. async apply() {
  126. this.reset();
  127. await Editor.Message.request('scene', 'apply-material', this.asset.uuid, this.material);
  128. },
  129. async abort() {
  130. this.reset();
  131. await Editor.Message.request('scene', 'preview-material', this.asset.uuid);
  132. },
  133. reset() {
  134. this.dirtyData.uuid = '';
  135. this.cacheData = {};
  136. },
  137. async change() {
  138. this.canUpdatePreview = true;
  139. await this.setDirtyData();
  140. this.dispatch('change');
  141. },
  142. snapshot() {
  143. this.dispatch('snapshot');
  144. },
  145. async updateEffect() {
  146. const effectMap = await Editor.Message.request('scene', 'query-all-effects');
  147. // see: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator
  148. const collator = new Intl.Collator(undefined, { numeric: true });
  149. this.effects = Object.values(effectMap)
  150. .filter((effect) => !effect.hideInEditor)
  151. .sort((a, b) => collator.compare(a.name, b.name));
  152. const effectOptionsHTML = renderGroupEffectOptions(this.effects);
  153. this.$.effect.innerHTML = effectOptionsHTML;
  154. this.$.effect.value = this.material.effect;
  155. setDisabled(this.asset.readonly, this.$.effect);
  156. },
  157. async updateInterface() {
  158. this.updateTechnique();
  159. const currentEffectInfo = this.effects.find((effect) => {
  160. return effect.name === this.material.effect;
  161. });
  162. this.customInterface = '';
  163. if (currentEffectInfo && currentEffectInfo.uuid) {
  164. const meta = await Editor.Message.request('asset-db', 'query-asset-meta', currentEffectInfo.uuid);
  165. if (meta && meta.userData && meta.userData.editor) {
  166. this.customInterface = meta.userData.editor.inspector;
  167. }
  168. }
  169. if (this.customInterface && this.customInterface.startsWith('packages://')) {
  170. try {
  171. const relatePath = normalize(this.customInterface.replace('packages://', ''));
  172. const name = relatePath.split(sep)[0];
  173. const packagePath = Editor.Package.getPackages({ name, enable: true })[0].path;
  174. const filePath = join(packagePath, relatePath.split(name)[1]);
  175. if (this.$.custom.getAttribute('src') !== filePath) {
  176. this.$.custom.injectionStyle(injectionStyle);
  177. this.$.custom.setAttribute('src', filePath);
  178. }
  179. this.$.custom.update(this.material, this.assetList, this.metaList);
  180. } catch (err) {
  181. console.error(err);
  182. console.error(Editor.I18n.t('ENGINE.assets.material.illegal-inspector-url'));
  183. }
  184. } else {
  185. this.$.custom.removeAttribute('src');
  186. this.updatePasses();
  187. }
  188. },
  189. updateTechnique() {
  190. let techniqueOption = '';
  191. this.material.data.forEach((technique, index) => {
  192. const name = technique.name ? `${index} - ${technique.name}` : index;
  193. techniqueOption += `<option value="${index}">${name}</option>`;
  194. });
  195. this.$.technique.innerHTML = techniqueOption;
  196. this.$.technique.value = this.material.technique;
  197. setDisabled(this.asset.readonly, this.$.technique);
  198. },
  199. async updatePasses() {
  200. const technique = materialTechniquePolyfill(this.material.data[this.material.technique]);
  201. this.technique = technique;
  202. if (!technique || !technique.passes) {
  203. return;
  204. }
  205. if (this.requestInitCache) {
  206. this.initCache();
  207. if (!this.canUpdatePreview) {
  208. await this.updatePreview(false);
  209. }
  210. } else {
  211. this.useCache();
  212. await this.updatePreview(true);
  213. }
  214. if (technique.passes) {
  215. // The interface is not a regular data loop, which needs to be completely cleared and placed, but the UI-prop element is still reusable
  216. const $container = this.$.materialDump;
  217. $container.innerText = '';
  218. if (!$container.$children) {
  219. $container.$children = {};
  220. }
  221. for (let i = 0; i < technique.passes.length; i++) {
  222. const pass = technique.passes[i];
  223. // if asset is readonly
  224. if (this.asset.readonly) {
  225. for (const key in pass.value) {
  226. loopSetAssetDumpDataReadonly(pass.value[key]);
  227. }
  228. }
  229. $container.$children[i] = document.createElement('ui-prop');
  230. $container.$children[i].setAttribute('type', 'dump');
  231. $container.$children[i].setAttribute('ui-section-config', '');
  232. $container.$children[i].setAttribute('pass-index', i);
  233. $container.appendChild($container.$children[i]);
  234. $container.$children[i].render(pass);
  235. // Add the checkbox given by the switch attribute
  236. if (pass.switch && pass.switch.name) {
  237. const $checkbox = document.createElement('ui-checkbox');
  238. $checkbox.innerText = pass.switch.name;
  239. $checkbox.setAttribute('slot', 'header');
  240. $checkbox.addEventListener('change', (e) => {
  241. pass.switch.value = e.target.value;
  242. });
  243. setReadonly(this.asset.readonly, $checkbox);
  244. $checkbox.value = pass.switch.value;
  245. const $section = $container.$children[i].querySelector('ui-section');
  246. $section.appendChild($checkbox);
  247. // header and switch element appear in `header` slot at the same time, keep the middle distance 12px
  248. const $header = $section.querySelector('div[slot=header]');
  249. $header.style.width = 'auto';
  250. $header.style.flex = '1';
  251. $header.style.minWidth = '0';
  252. $header.style.marginRight = '12px';
  253. }
  254. $container.$children[i].querySelectorAll('ui-prop').forEach(($prop) => {
  255. const dump = $prop.dump;
  256. if (dump && dump.childMap && dump.children.length) {
  257. if (!$prop.$childMap) {
  258. $prop.$childMap = document.createElement('section');
  259. $prop.$childMap.setAttribute(
  260. 'style',
  261. 'margin-left: var(--ui-prop-margin-left, unset);',
  262. );
  263. $prop.$childMap.$props = {};
  264. for (const childName in dump.childMap) {
  265. if (dump.childMap[childName].value === undefined) {
  266. continue;
  267. }
  268. if (this.asset.readonly) {
  269. loopSetAssetDumpDataReadonly(dump.childMap[childName]);
  270. }
  271. $prop.$childMap.$props[childName] = document.createElement('ui-prop');
  272. $prop.$childMap.$props[childName].setAttribute('type', 'dump');
  273. $prop.$childMap.$props[childName].render(dump.childMap[childName]);
  274. $prop.$childMap.appendChild($prop.$childMap.$props[childName]);
  275. }
  276. if (Array.from($prop.$childMap.children).length) {
  277. $prop.after($prop.$childMap);
  278. }
  279. $prop.addEventListener('change-dump', (e) => {
  280. if (e.target.dump.value) {
  281. $prop.$childMap.removeAttribute('hidden');
  282. } else {
  283. $prop.$childMap.setAttribute('hidden', '');
  284. }
  285. });
  286. }
  287. if (dump.value) {
  288. $prop.$childMap.removeAttribute('hidden');
  289. } else {
  290. $prop.$childMap.setAttribute('hidden', '');
  291. }
  292. }
  293. });
  294. }
  295. // when passes length more than one, the ui-section of pipeline state collapse
  296. if (technique.passes.length > 1) {
  297. $container.querySelectorAll('[cache-expand$="PassStates"]').forEach(($pipelineState) => {
  298. const cacheExpand = $pipelineState.getAttribute('cache-expand');
  299. if (!this.defaultCollapsePasses[cacheExpand]) {
  300. $pipelineState.expand = false;
  301. this.defaultCollapsePasses[cacheExpand] = true;
  302. }
  303. });
  304. }
  305. }
  306. this.updateInstancing();
  307. },
  308. updateInstancing() {
  309. const technique = this.technique;
  310. const firstPass = technique.passes[0];
  311. if (firstPass.childMap.USE_INSTANCING) {
  312. technique.useInstancing.value = firstPass.childMap.USE_INSTANCING.value;
  313. this.changeInstancing(technique.useInstancing.value);
  314. }
  315. if (technique.useInstancing) {
  316. this.$.useInstancing.render(technique.useInstancing);
  317. setHidden(technique.useInstancing && !technique.useInstancing.visible, this.$.useInstancing);
  318. setReadonly(this.asset.readonly, this.$.useInstancing);
  319. }
  320. },
  321. async updatePreview(emit) {
  322. await Editor.Message.request('scene', 'preview-material', this.asset.uuid, this.material, { emit });
  323. Editor.Message.broadcast('material-inspector:change-dump');
  324. },
  325. changeInstancing(checked) {
  326. this.technique.passes.forEach((pass) => {
  327. if (pass.childMap.USE_INSTANCING) {
  328. pass.childMap.USE_INSTANCING.value = checked;
  329. }
  330. });
  331. },
  332. initCache() {
  333. const excludeNames = [
  334. 'children',
  335. 'defines',
  336. 'extends',
  337. ];
  338. const cacheData = this.cacheData;
  339. this.technique.passes.forEach((pass, i) => {
  340. if (isNil(pass.propertyIndex)) {
  341. return;
  342. }
  343. cacheProperty(pass.value, i);
  344. });
  345. function cacheProperty(prop, passIndex) {
  346. for (const name in prop) {
  347. // 这些字段是基础类型或配置性的数据,不需要变动
  348. if (excludeNames.includes(name)) {
  349. continue;
  350. }
  351. if (prop[name] && typeof prop[name] === 'object') {
  352. if (!cacheData[name]) {
  353. cacheData[name] = {};
  354. }
  355. const { type, value, isObject } = prop[name];
  356. if (type && value !== undefined) {
  357. if (!cacheData[name][passIndex]) {
  358. if (name === 'USE_INSTANCING') {
  359. continue;
  360. }
  361. cacheData[name][passIndex] = { type };
  362. if (value && typeof value === 'object') {
  363. cacheData[name][passIndex].value = JSON.parse(JSON.stringify(value));
  364. } else {
  365. cacheData[name][passIndex].value = value;
  366. }
  367. }
  368. }
  369. if (isObject) {
  370. cacheProperty(value, passIndex);
  371. } else if (prop[name].childMap && typeof prop[name].childMap === 'object') {
  372. cacheProperty(prop[name].childMap, passIndex);
  373. }
  374. }
  375. }
  376. }
  377. this.requestInitCache = false;
  378. this.updateInstancing();
  379. },
  380. storeCache(dump, passIndex) {
  381. const { name, type, value, default: defaultValue } = dump;
  382. if (JSON.stringify(value) === JSON.stringify(defaultValue)) {
  383. if (this.cacheData[name] && this.cacheData[name][passIndex] !== undefined) {
  384. delete this.cacheData[name][passIndex];
  385. }
  386. } else {
  387. const cacheData = this.cacheData;
  388. if (!cacheData[name]) {
  389. cacheData[name] = {};
  390. }
  391. cacheData[name][passIndex] = JSON.parse(JSON.stringify({ type, value }));
  392. }
  393. },
  394. useCache() {
  395. const cacheData = this.cacheData;
  396. this.technique.passes.forEach((pass, i) => {
  397. if (isNil(pass.propertyIndex)) {
  398. return;
  399. }
  400. updateProperty(pass.value, i);
  401. });
  402. function updateProperty(prop, passIndex) {
  403. for (const name in prop) {
  404. if (prop[name] && typeof prop[name] === 'object') {
  405. if (name in cacheData) {
  406. const passItem = cacheData[name][passIndex];
  407. if (passItem) {
  408. const { type, value } = passItem;
  409. if (prop[name].type === type && JSON.stringify(prop[name].value) !== JSON.stringify(value)) {
  410. if (value && typeof value === 'object') {
  411. prop[name].value = JSON.parse(JSON.stringify(value));
  412. } else {
  413. prop[name].value = value;
  414. }
  415. }
  416. }
  417. }
  418. if (prop[name].isObject) {
  419. updateProperty(prop[name].value, passIndex);
  420. } else if (prop[name].childMap && typeof prop[name].childMap === 'object') {
  421. updateProperty(prop[name].childMap, passIndex);
  422. }
  423. }
  424. }
  425. }
  426. },
  427. async setDirtyData() {
  428. this.dirtyData.realtime = JSON.stringify({
  429. effect: this.material.effect,
  430. technique: this.material.technique,
  431. techniqueData: this.material.data[this.material.technique],
  432. });
  433. if (!this.dirtyData.origin) {
  434. this.dirtyData.origin = this.dirtyData.realtime;
  435. this.dispatch('snapshot');
  436. }
  437. if (this.canUpdatePreview) {
  438. await this.updatePreview(true);
  439. }
  440. },
  441. isDirty() {
  442. const isDirty = this.dirtyData.origin !== this.dirtyData.realtime;
  443. return isDirty;
  444. },
  445. };
  446. /**
  447. * Methods for automatic rendering of components
  448. * @param assetList
  449. * @param metaList
  450. */
  451. exports.update = async function(assetList, metaList) {
  452. this.assetList = assetList;
  453. this.metaList = metaList;
  454. this.asset = assetList[0];
  455. this.meta = metaList[0];
  456. // 增加容错
  457. if (!this.$this.isConnected) {
  458. return;
  459. }
  460. if (assetList.length !== 1) {
  461. this.$.invalid.setAttribute('active', '');
  462. return;
  463. } else {
  464. this.$.invalid.removeAttribute('active');
  465. }
  466. if (this.dirtyData.uuid !== this.asset.uuid) {
  467. this.dirtyData.uuid = this.asset.uuid;
  468. this.dirtyData.origin = '';
  469. this.dirtyData.realtime = '';
  470. this.cacheData = {};
  471. this.requestInitCache = true;
  472. }
  473. this.material = await Editor.Message.request('scene', 'query-material', this.asset.uuid);
  474. await this.updateEffect();
  475. await this.updateInterface();
  476. await this.setDirtyData();
  477. };
  478. /**
  479. * Method of initializing the panel
  480. */
  481. exports.ready = function() {
  482. this.defaultCollapsePasses = {};
  483. this.canUpdatePreview = false;
  484. // Used to determine whether the material has been modified in isDirty()
  485. this.dirtyData = {
  486. uuid: '',
  487. origin: '',
  488. realtime: '',
  489. };
  490. // Retain the previously modified data when switching pass
  491. this.cacheData = {};
  492. // The event that is triggered when the effect used is modified
  493. this.$.effect.addEventListener('change', async (event) => {
  494. this.material.effect = event.target.value;
  495. this.material.data = await Editor.Message.request('scene', 'query-effect', this.material.effect);
  496. // change effect then make technique back to 0
  497. this.$.technique.value = this.material.technique = 0;
  498. await this.updateInterface();
  499. await this.change();
  500. this.snapshot();
  501. });
  502. this.$.location.addEventListener('change', () => {
  503. const effect = this.effects.find((_effect) => _effect.name === this.material.effect);
  504. if (effect) {
  505. Editor.Message.send('assets', 'twinkle', effect.uuid);
  506. }
  507. });
  508. // Event triggered when the technique being used is changed
  509. this.$.technique.addEventListener('change', async (event) => {
  510. this.material.technique = Number(event.target.value);
  511. await this.updateInterface();
  512. await this.change();
  513. this.snapshot();
  514. });
  515. // The event is triggered when the useInstancing is modified
  516. this.$.useInstancing.addEventListener('change-dump', async (event) => {
  517. this.changeInstancing(event.target.dump.value);
  518. this.storeCache(event.target.dump, 0);
  519. await this.change();
  520. this.snapshot();
  521. });
  522. // The event triggered when the content of material is modified
  523. this.$.materialDump.addEventListener('change-dump', async (event) => {
  524. const dump = event.target.dump;
  525. if (!event.path) {
  526. event.path = event.composedPath();
  527. }
  528. let passIndex = 0;
  529. for (let element of event.path) {
  530. if (element instanceof HTMLElement && element.hasAttribute('pass-index')) {
  531. passIndex = Number(element.getAttribute('pass-index'));
  532. break;
  533. }
  534. }
  535. this.storeCache(dump, passIndex);
  536. await this.change();
  537. });
  538. this.$.materialDump.addEventListener('confirm-dump', () => {
  539. this.snapshot();
  540. });
  541. this.$.custom.addEventListener('change', () => {
  542. this.change();
  543. });
  544. this.$.custom.addEventListener('snapshot', () => {
  545. this.snapshot();
  546. });
  547. };
  548. exports.close = function() {
  549. // Used to determine whether the material has been modified in isDirty()
  550. this.dirtyData = {
  551. uuid: '',
  552. origin: '',
  553. realtime: '',
  554. };
  555. this.cacheData = {};
  556. };