1206 lines
42 KiB
JavaScript
1206 lines
42 KiB
JavaScript
/*
|
||
* Copyright (C) 2023 The Android Open Source Project
|
||
*
|
||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||
* you may not use this file except in compliance with the License.
|
||
* You may obtain a copy of the License at
|
||
*
|
||
* http://www.apache.org/licenses/LICENSE-2.0
|
||
*
|
||
* Unless required by applicable law or agreed to in writing, software
|
||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
* See the License for the specific language governing permissions and
|
||
* limitations under the License.
|
||
*/
|
||
|
||
import { LitElement, html, css, unsafeCSS, nothing } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js";
|
||
|
||
const kUntitledPlaceholder = "untitled";
|
||
|
||
// Maps to backend to the languages allowed for that backend.
|
||
const LANGUAGE_CHOICES = {
|
||
'opengl': ['essl3', 'essl1'],
|
||
'essl1': ['essl3', 'essl1'],
|
||
'vulkan': ['glsl', 'spirv'],
|
||
'metal': ['msl'],
|
||
'webgpu': ['wgsl'],
|
||
};
|
||
|
||
const DEFAULT_LANGUAGE_FOR_BACKEND = {
|
||
'opengl': 'essl3',
|
||
'essl1': 'essl1',
|
||
'vulkan': 'glsl',
|
||
'metal': 'msl',
|
||
'webgpu': 'wgsl',
|
||
};
|
||
|
||
const BACKENDS = Object.keys(LANGUAGE_CHOICES);
|
||
|
||
const MATERIAL_INFO_KEY_TO_STRING = {
|
||
'model': 'shading model',
|
||
'vertex_domain': 'vertex domain',
|
||
'interpolation': 'interpolation',
|
||
'shadow_multiply': 'shadow multiply',
|
||
'specular_antialiasing': 'specular antialiasing',
|
||
'variance': 'variance',
|
||
'threshold': 'threshold',
|
||
'clear_coat_IOR_change': 'clear coat IOR change',
|
||
'blending': 'blending',
|
||
'mask_threshold': 'mask threshold',
|
||
'color_write': 'color write',
|
||
'depth_write': 'depth write',
|
||
'depth_test': 'depth test',
|
||
'double_sided': 'double sided',
|
||
'culling': 'culling',
|
||
'transparency': 'transparency',
|
||
};
|
||
|
||
// CSS constants
|
||
const FOREGROUND_COLOR = '#fafafa';
|
||
const INACTIVE_COLOR = '#9a9a9a';
|
||
const DARKER_INACTIVE_COLOR = '#6f6f6f';
|
||
const LIGHTER_INACTIVE_COLOR = '#d9d9d9';
|
||
const UNSELECTED_COLOR = '#d0d0d0';
|
||
const BACKGROUND_COLOR = '#6885c5';
|
||
const HOVER_BACKGROUND_COLOR = '#b3c2ff';
|
||
const CODE_VIEWER_BOTTOM_ROW_HEIGHT = 60;
|
||
const REGULAR_FONT_SIZE = 12;
|
||
|
||
// Set up the Monaco editor. See also CodeViewer
|
||
const kMonacoBaseUrl = 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.52.2/min/';
|
||
require.config({
|
||
paths: { "vs": `${kMonacoBaseUrl}vs` },
|
||
'vs/css': { disabled: true },
|
||
});
|
||
window.MonacoEnvironment = {
|
||
getWorkerUrl: function() {
|
||
return `data:text/javascript;charset=utf-8,${encodeURIComponent(`
|
||
self.MonacoEnvironment = {
|
||
baseUrl: '${kMonacoBaseUrl}'
|
||
};
|
||
importScripts('${kMonacoBaseUrl}vs/base/worker/workerMain.js');`
|
||
)}`;
|
||
}
|
||
};
|
||
|
||
const _validDict = (obj) => {
|
||
return obj && Object.keys(obj).length > 0;
|
||
}
|
||
|
||
const _isMatInfoMode = (database) => {
|
||
return Object.keys(database).length == 1;
|
||
}
|
||
|
||
class Button extends LitElement {
|
||
static get styles() {
|
||
return css`
|
||
:host {
|
||
display: flex;
|
||
}
|
||
.main {
|
||
border: solid 2px ${unsafeCSS(BACKGROUND_COLOR)};
|
||
border-radius: 5px;
|
||
font-size: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 30px;
|
||
padding: 1px 8px;
|
||
color: ${unsafeCSS(BACKGROUND_COLOR)};
|
||
margin: 5px 10px;
|
||
width: 100px;
|
||
}
|
||
.main:hover {
|
||
background: ${unsafeCSS(HOVER_BACKGROUND_COLOR)};
|
||
}
|
||
.enabled {
|
||
cursor: pointer;
|
||
}
|
||
.disabled:hover {
|
||
background: ${unsafeCSS(LIGHTER_INACTIVE_COLOR)};
|
||
}
|
||
.disabled {
|
||
color: ${unsafeCSS(INACTIVE_COLOR)};
|
||
border: solid 2px ${unsafeCSS(INACTIVE_COLOR)};
|
||
background: ${unsafeCSS(LIGHTER_INACTIVE_COLOR)};
|
||
}
|
||
`;
|
||
}
|
||
static get properties() {
|
||
return {
|
||
label: {type: String, attribute: 'label'},
|
||
enabled: {type: Boolean, attribute: 'enabled'},
|
||
}
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this.label = '';
|
||
this.enabled = false;
|
||
}
|
||
|
||
_onClick(ev) {
|
||
this.dispatchEvent(new CustomEvent('button-clicked', {bubbles: true, composed: true}));
|
||
}
|
||
|
||
render() {
|
||
let divClass = 'main';
|
||
if (this.enabled) {
|
||
divClass += ' enabled';
|
||
} else {
|
||
divClass += ' disabled';
|
||
}
|
||
return html`
|
||
<div class="${divClass}" @click="${this._onClick}">
|
||
${this.label}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
customElements.define("custom-button", Button);
|
||
|
||
class CodeViewer extends LitElement {
|
||
static get styles() {
|
||
return css`
|
||
:host {
|
||
background: white;
|
||
width:100%;
|
||
padding-top: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
#editor {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
#bottom-row {
|
||
width: 100%;
|
||
display: flex;
|
||
height: ${unsafeCSS(CODE_VIEWER_BOTTOM_ROW_HEIGHT)}px;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
border-top: solid 1px ${unsafeCSS(BACKGROUND_COLOR)};
|
||
}
|
||
.hide {
|
||
display: none;
|
||
}
|
||
.reminder {
|
||
height: 100%;
|
||
width: 100%;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 20px;
|
||
color: ${unsafeCSS(BACKGROUND_COLOR)};
|
||
}
|
||
.stateText {
|
||
color: ${unsafeCSS(INACTIVE_COLOR)};
|
||
padding: 0 10px;
|
||
}
|
||
`;
|
||
}
|
||
|
||
static get properties() {
|
||
return {
|
||
connected: {type: Boolean, attribute: 'connected'},
|
||
code: {type: String, state: true},
|
||
active: {type: Boolean, attribute: 'active'},
|
||
modified: {type: Boolean, attribute: 'modified'},
|
||
expectedWidth: {type: Number, attribute: 'expected-width'},
|
||
expectedHeight: {type: Number, attribute: 'expected-height'},
|
||
}
|
||
}
|
||
|
||
get _editorDiv() {
|
||
return this.renderRoot.querySelector('#editor');
|
||
}
|
||
|
||
firstUpdated() {
|
||
const innerStyle = document.createElement('style');
|
||
innerStyle.innerText = `@import "${kMonacoBaseUrl}/vs/editor/editor.main.css";`;
|
||
this.renderRoot.appendChild(innerStyle);
|
||
|
||
require(["vs/editor/editor.main"], () => {
|
||
this.editor = monaco.editor.create(this._editorDiv, {
|
||
language: "cpp",
|
||
scrollBeyondLastLine: false,
|
||
readOnly: false,
|
||
minimap: { enabled: false },
|
||
automaticLayout: true,
|
||
|
||
// Workaround see https://github.com/microsoft/monaco-editor/issues/3217
|
||
fontLigatures: '',
|
||
});
|
||
const KeyMod = monaco.KeyMod, KeyCode = monaco.KeyCode;
|
||
this.editor.onDidChangeModelContent(this._onEdit.bind(this));
|
||
this.editor.addCommand(KeyMod.CtrlCmd | KeyCode.KEY_S, this._rebuild.bind(this));
|
||
|
||
// It might be that the code is available before the editor has been created.
|
||
if (this.code && this.code.length > 0) {
|
||
this.editor.setValue(this.code);
|
||
}
|
||
});
|
||
}
|
||
|
||
_onEdit(edit) {
|
||
// If the edit is the loading of the entire code, we ignore the edit.
|
||
if (edit.changes[0].text.length == this.code.length) {
|
||
return;
|
||
}
|
||
this.dispatchEvent(new CustomEvent(
|
||
'shader-edited',
|
||
{detail: this.editor.getValue(), bubbles: true, composed: true}
|
||
));
|
||
}
|
||
|
||
_rebuild() {
|
||
if (!this.active || !this.modified) {
|
||
console.log('Called rebuild while variant is inactive or unmodified');
|
||
return;
|
||
}
|
||
this.dispatchEvent(new CustomEvent(
|
||
'rebuild-shader',
|
||
{detail: this.editor.getValue(), bubbles: true, composed: true}
|
||
));
|
||
}
|
||
|
||
updated(props) {
|
||
if (props.has('code') && this.code.length > 0) {
|
||
// Note that the prop might have been updated before the editor is available.
|
||
if (this.editor) {
|
||
this.editor.setValue(this.code);
|
||
}
|
||
}
|
||
if ((props.has('expectedWidth') || props.has('expectedHeight')) &&
|
||
(this.expectedWidth > 0 && (this.expectedHeight - CODE_VIEWER_BOTTOM_ROW_HEIGHT) > 0)) {
|
||
const actualWidth = Math.floor(this.expectedWidth);
|
||
const actualHeight = (Math.floor(this.expectedHeight) - CODE_VIEWER_BOTTOM_ROW_HEIGHT);
|
||
this._editorDiv.style.width = actualWidth + 'px';
|
||
this._editorDiv.style.height = actualHeight + 'px';
|
||
}
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this.code = '';
|
||
this.active = false;
|
||
this.modified = false;
|
||
this.addEventListener('button-clicked', this._rebuild.bind(this));
|
||
this.expectedWidth = 0;
|
||
this.expectedHeight = 0;
|
||
}
|
||
|
||
render() {
|
||
let divClass = '';
|
||
let reminder = null;
|
||
if (this.code.length == 0) {
|
||
divClass += ' hide';
|
||
reminder = (() => html`<div class="reminder">Please select a shader in the side panel.</div>`)();
|
||
}
|
||
let stateText = null;
|
||
if (!this.connected) {
|
||
stateText = 'disconnected';
|
||
} else if (this.code.length > 0 && !this.active) {
|
||
stateText = 'inactive variant/shader';
|
||
} else if (this.code.length > 0 &&!this.modified) {
|
||
stateText = 'source unmodified';
|
||
}
|
||
|
||
const stateDiv = stateText ? (() => html`
|
||
<div class="stateText">${stateText}</div>
|
||
`)(): null;
|
||
|
||
return html`
|
||
<div class="${divClass}" id="editor"></div>
|
||
${reminder ?? nothing}
|
||
<div id="bottom-row">
|
||
<div style="display:flex;flex-direction:row;align-items:center">
|
||
${stateDiv ?? nothing}
|
||
<custom-button class="${divClass}"
|
||
label="Rebuild"
|
||
?enabled="${this.active && this.modified}">
|
||
</custom-button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
customElements.define("code-viewer", CodeViewer);
|
||
|
||
class MenuSection extends LitElement {
|
||
static get properties() {
|
||
return {
|
||
showing: {type: Boolean, state: true},
|
||
title: {type: String, attribute: 'title'},
|
||
};
|
||
}
|
||
|
||
static get styles() {
|
||
return css`
|
||
:host {
|
||
font-size: ${unsafeCSS(REGULAR_FONT_SIZE)}px;
|
||
color: ${unsafeCSS(UNSELECTED_COLOR)};
|
||
}
|
||
.section-title {
|
||
font-size: 20px;
|
||
color: ${unsafeCSS(UNSELECTED_COLOR)};
|
||
cursor: pointer;
|
||
}
|
||
.container {
|
||
margin-bottom: 20px;
|
||
}
|
||
hr {
|
||
display: block;
|
||
height: 1px;
|
||
border: 0px;
|
||
border-top: 1px solid ${unsafeCSS(UNSELECTED_COLOR)};
|
||
padding: 0;
|
||
width: 100%;
|
||
margin: 3px 0 8px 0;
|
||
}
|
||
.expander {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.expanded-title {
|
||
color: ${unsafeCSS(FOREGROUND_COLOR)};
|
||
}
|
||
`;
|
||
}
|
||
|
||
_showClick() {
|
||
this.showing = !this.showing;
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this.showing = true;
|
||
}
|
||
|
||
render() {
|
||
const expandedIcon = this.showing ? '-' : '+';
|
||
const slot = (() => html`<slot></slot>`)();
|
||
const epandedTitleClass = this.showing ? 'expanded-title' : '';
|
||
return html`
|
||
<div class="container">
|
||
<div class="section-title expander ${epandedTitleClass}" @click="${this._showClick}">
|
||
<span>${this.title}</span> <span>${expandedIcon}</span>
|
||
</div>
|
||
<hr />
|
||
${this.showing ? slot : []}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
customElements.define('menu-section', MenuSection);
|
||
|
||
class MaterialInfo extends LitElement {
|
||
static get properties() {
|
||
return {
|
||
info: {type: Object, state: true},
|
||
};
|
||
}
|
||
|
||
static get styles() {
|
||
return css`
|
||
:host {
|
||
font-size: ${unsafeCSS(REGULAR_FONT_SIZE)}px;
|
||
}
|
||
`;
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this.info = null;
|
||
}
|
||
|
||
_hasInfo() {
|
||
return this.info && Object.keys(this.info).length > 0;
|
||
}
|
||
|
||
render() {
|
||
let infoDivs = [];
|
||
if (this._hasInfo()) {
|
||
if (this.info.shading && this.info.shading.material_domain === 'surface') {
|
||
infoDivs = infoDivs.concat(
|
||
Object.keys(this.info.shading)
|
||
.filter((propKey) => (propKey in MATERIAL_INFO_KEY_TO_STRING))
|
||
.map((propKey) => html`
|
||
<div key="${propKey}" class="info-item">
|
||
${MATERIAL_INFO_KEY_TO_STRING[propKey]} = ${this.info.shading[propKey]}
|
||
</div>
|
||
`)
|
||
);
|
||
}
|
||
if (this.info.raster) {
|
||
infoDivs = infoDivs.concat(
|
||
Object.keys(this.info.raster)
|
||
.filter((propKey) => (propKey in MATERIAL_INFO_KEY_TO_STRING))
|
||
.map((propKey) => html`
|
||
<div key=${propKey} class="info-item">
|
||
${MATERIAL_INFO_KEY_TO_STRING[propKey]} = ${this.info.raster[propKey]}
|
||
</div>
|
||
`)
|
||
);
|
||
}
|
||
}
|
||
const shouldHide = infoDivs.length == 0;
|
||
if (infoDivs.length > 0) {
|
||
return html`
|
||
<menu-section title="Material Details">
|
||
${infoDivs}
|
||
</menu-section>
|
||
`;
|
||
}
|
||
return html``;
|
||
}
|
||
}
|
||
customElements.define('material-info', MaterialInfo);
|
||
|
||
class AdvancedOptions extends LitElement {
|
||
static get properties() {
|
||
return {
|
||
currentBackend: {type: String, attribute: 'current-backend'},
|
||
currentShaderModel: {type: String, attribute: 'current-shader-model'},
|
||
hideInactiveVariants: {type: Boolean, attribute: 'hide-inactive-variants'},
|
||
availableBackends: {type: Array, state: true},
|
||
};
|
||
}
|
||
|
||
static get styles() {
|
||
return css`
|
||
:host {
|
||
font-size: ${unsafeCSS(REGULAR_FONT_SIZE)}px;
|
||
}
|
||
.option {
|
||
border: 1px solid ${unsafeCSS(UNSELECTED_COLOR)};
|
||
border-radius: 5px;
|
||
padding: 4px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: flex-start;
|
||
margin-bottom: 6px;
|
||
}
|
||
.borderless {
|
||
border: 1px solid rgba(0,0,0,0);
|
||
}
|
||
label {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-right: 5px;
|
||
}
|
||
label input {
|
||
margin: 0 4px 0 0;
|
||
}
|
||
form {
|
||
display: flex;
|
||
}
|
||
.option-heading {
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
/* Below is a custom checkbox */
|
||
input[type="checkbox"] {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
background-color: var(--form-background);
|
||
margin-right: 5px;
|
||
font: inherit;
|
||
width: 1.15em;
|
||
height: 1.15em;
|
||
border: 0.15em solid ${unsafeCSS(UNSELECTED_COLOR)};
|
||
border-radius: 0.15em;
|
||
display: grid;
|
||
place-content: center;
|
||
}
|
||
input[type="checkbox"]::before {
|
||
content: "";
|
||
width: 0.65em;
|
||
height: 0.65em;
|
||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||
transform: scale(0);
|
||
transform-origin: bottom left;
|
||
box-shadow: inset 1em 1em var(--form-control-color);
|
||
/* Windows High Contrast Mode */
|
||
background-color: ${unsafeCSS(FOREGROUND_COLOR)};
|
||
}
|
||
input[type="checkbox"]:checked::before {
|
||
transform: scale(1);
|
||
}
|
||
`;
|
||
}
|
||
|
||
get _backendOptionForm() {
|
||
return this.renderRoot.querySelector('#backend-option-form');
|
||
}
|
||
|
||
updated(props) {
|
||
if (props.has('currentBackend') || props.has('availableBackends')) {
|
||
// Clear the radio button selections. The correct option will be selected
|
||
// in _backendOption().
|
||
if (this._backendOptionForm) {
|
||
this._backendOptionForm.reset();
|
||
}
|
||
}
|
||
}
|
||
|
||
_backendOption() {
|
||
if (this.availableBackends.length == 0) {
|
||
return null;
|
||
}
|
||
|
||
const onChange = (ev) => {
|
||
const backend = ev.currentTarget.getAttribute('name');
|
||
this.dispatchEvent(
|
||
new CustomEvent(
|
||
'option-backend',
|
||
{detail: backend, bubbles: true, composed: true}));
|
||
}
|
||
const div = this.availableBackends.map((backend) => {
|
||
const selected = backend == this.currentBackend;
|
||
return html`
|
||
<label>
|
||
<input type="radio" name="${backend}"
|
||
?checked=${selected} @change=${onChange}/>
|
||
${backend}
|
||
</label>
|
||
`;
|
||
});
|
||
|
||
return html`
|
||
<div class="option">
|
||
<div class="option-heading">Current Backend</div>
|
||
<form style="display:flex;flex-wrap:wrap" action="" id="backend-option-form">
|
||
${div}
|
||
</form>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
_hideInactiveVariantsOption() {
|
||
if (this.currentShaderModel == 'matinfo') {
|
||
return null;
|
||
}
|
||
const onChange = (ev) => {
|
||
this.dispatchEvent(
|
||
new CustomEvent(
|
||
'option-hide-inactive-variants',
|
||
{detail: null, bubbles: true, composed: true}));
|
||
}
|
||
return html`
|
||
<div class="option">
|
||
<!--<div class="option-heading">Current Backend</div>-->
|
||
<form action="" id="hide-inactive-variants-form">
|
||
<input type="checkbox" name="InactiveVariants"
|
||
?checked=${this.hideInactiveVariants} @change=${onChange}/>
|
||
Hide Inactive Variants
|
||
</form>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this.availableBackends = [];
|
||
}
|
||
|
||
render() {
|
||
return html`
|
||
<menu-section title="Advanced Options">
|
||
${this._hideInactiveVariantsOption() ?? nothing}
|
||
${this._backendOption() ?? nothing}
|
||
</menu-section>
|
||
`;
|
||
}
|
||
}
|
||
customElements.define('advanced-options', AdvancedOptions);
|
||
|
||
|
||
class MaterialSidePanel extends LitElement {
|
||
// Setting the style in render() has poor performance implications. We use it simply to avoid
|
||
// having another container descending from the root to host the background color.
|
||
dynamicStyle() {
|
||
return `
|
||
:host {
|
||
background: ${this.connected ? BACKGROUND_COLOR : DARKER_INACTIVE_COLOR};
|
||
width:100%;
|
||
max-width: 250px;
|
||
min-width: 180px;
|
||
padding: 10px 20px;
|
||
overflow-y: auto;
|
||
}
|
||
.title {
|
||
color: white;
|
||
width: 100%;
|
||
text-align: center;
|
||
margin: 0 0 20px 0;
|
||
font-size: 25px;
|
||
}
|
||
.materials {
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin-bottom: 20px;
|
||
font-size: ${REGULAR_FONT_SIZE}px;
|
||
color: ${UNSELECTED_COLOR};
|
||
}
|
||
.material_variant_language:hover {
|
||
text-decoration: underline;
|
||
}
|
||
.material_variant_language {
|
||
cursor: pointer;
|
||
}
|
||
.selected {
|
||
font-weight: bolder;
|
||
color: ${FOREGROUND_COLOR};
|
||
}
|
||
.inactive {
|
||
color: ${INACTIVE_COLOR};
|
||
}
|
||
.variant-list {
|
||
padding-left: 20px;
|
||
}
|
||
.language {
|
||
margin: 0 8px 0 0;
|
||
}
|
||
.languages {
|
||
padding-left: 20px;
|
||
flex-direction: row;
|
||
display: flex;
|
||
}
|
||
`;
|
||
}
|
||
|
||
static get properties() {
|
||
return {
|
||
connected: {type: Boolean, attribute: 'connected'},
|
||
currentMaterial: {type: String, attribute: 'current-material'},
|
||
currentShaderIndex: {type: Number, attribute: 'current-shader-index'},
|
||
currentBackend: {type: String, attribute: 'current-backend'},
|
||
currentLanguage: {type: String, attribute: 'current-language'},
|
||
hideInactiveVariants: {type: Boolean, attribute: 'hide-inactive-variants'},
|
||
currentShaderModel: {type: String, attribute: 'current-shader-model'},
|
||
|
||
database: {type: Object, state: true},
|
||
materials: {type: Array, state: true},
|
||
activeShaders: {type: Object, state: true},
|
||
variants: {type: Array, state: true},
|
||
}
|
||
}
|
||
|
||
get _materialInfo() {
|
||
return this.renderRoot.querySelector('#material-info');
|
||
}
|
||
|
||
get _advancedOptions() {
|
||
return this.renderRoot.querySelector('#advanced-options');
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this.connected = false;
|
||
this.materials = [];
|
||
this.database = {};
|
||
this.activeShaders = {};
|
||
this.variants = [];
|
||
}
|
||
|
||
updated(props) {
|
||
if (props.has('database')) {
|
||
// Names need not be unique, so we display a numeric suffix for non-unique names.
|
||
// To achieve stable ordering of anonymous materials, we first sort by matid.
|
||
const matids = Object.keys(this.database).sort();
|
||
const names = matids.map(matid => (this.database[matid].name || kUntitledPlaceholder));
|
||
const labelCount = {};
|
||
names.forEach(name => labelCount[name] = (labelCount[name] || 0) + 1);
|
||
const duplicates = names.filter(name => labelCount[name] > 1);
|
||
|
||
this.materials = matids.map((matid) => {
|
||
const material = this.database[matid];
|
||
let name = material.name || kUntitledPlaceholder;
|
||
if (duplicates.includes(name)) {
|
||
name = `${name} (${labelCount[name]--})`;
|
||
}
|
||
return {
|
||
matid: matid,
|
||
name: name,
|
||
domain: material.shading.material_domain === "surface" ? "surface" : "postpro",
|
||
active: material.active,
|
||
};
|
||
});
|
||
}
|
||
if (props.has('currentMaterial') || props.has('currentBackend')) {
|
||
if (this.currentBackend && this.database && this.currentMaterial) {
|
||
const material = this.database[this.currentMaterial];
|
||
const activeVariants = _validDict(this.activeShaders) ? this.activeShaders[this.currentMaterial].variants : [];
|
||
const materialShaders = material[this.currentBackend];
|
||
let variants = [];
|
||
for (const [index, shader] of materialShaders.entries()) {
|
||
const active = activeVariants.indexOf(shader.variant) >= 0;
|
||
variants.push({
|
||
active,
|
||
shader,
|
||
});
|
||
}
|
||
this.variants = variants;
|
||
}
|
||
|
||
if (this.currentMaterial && this.database) {
|
||
const material = this.database[this.currentMaterial];
|
||
this._materialInfo.info = material;
|
||
|
||
// The matinfo usecase
|
||
if (_isMatInfoMode(this.database)) {
|
||
this._advancedOptions.availableBackends = BACKENDS.filter((backend) => !!material[backend]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
_handleMaterialClick(matid, ev) {
|
||
this.dispatchEvent(new CustomEvent('select-material', {detail: matid, bubbles: true, composed: true}));
|
||
}
|
||
|
||
_handleVariantClick(shaderIndex, ev) {
|
||
this.dispatchEvent(new CustomEvent('select-variant', {detail: shaderIndex, bubbles: true, composed: true}));
|
||
}
|
||
|
||
_handleLanguageClick(lang, ev) {
|
||
this.dispatchEvent(new CustomEvent('select-language', {detail: lang, bubbles: true, composed: true}));
|
||
}
|
||
|
||
_buildLanguagesDiv(isActive) {
|
||
const languages = LANGUAGE_CHOICES[this.currentBackend];
|
||
if (!languages || languages.length == 1) {
|
||
return null;
|
||
}
|
||
const languagesDiv = languages.map((lang) => {
|
||
const isLanguageSelected = lang === this.currentLanguage;
|
||
let divClass =
|
||
'material_variant_language language' +
|
||
(isLanguageSelected ? ' selected' : '') +
|
||
(!isActive && this.currentShaderModel != 'matinfo' ? ' inactive' : '');
|
||
const onClickLanguage = this._handleLanguageClick.bind(this, lang);
|
||
lang = (isLanguageSelected ? '● ' : '') + lang;
|
||
return html`
|
||
<div class="${divClass}" @click="${onClickLanguage}">
|
||
${lang}
|
||
</div>
|
||
`;
|
||
});
|
||
return html`<div class="languages">${languagesDiv}</div>`;
|
||
}
|
||
|
||
_buildShaderDiv() {
|
||
if (!this.variants) {
|
||
return null;
|
||
}
|
||
const isMatinfo = this.currentShaderModel == 'matinfo';
|
||
let variants =
|
||
this.variants
|
||
.sort((a, b) => {
|
||
// Place the active variants up top.
|
||
if (a.active && !b.active) return -1;
|
||
if (b.active && !a.active) return 1;
|
||
return 0;
|
||
})
|
||
.filter(variant => (!this.hideInactiveVariants || variant.shader.active))
|
||
.filter(variant => (
|
||
isMatinfo || variant.shader.shaderModel == this.currentShaderModel
|
||
))
|
||
.map((variant) => {
|
||
let divClass = 'material_variant_language';
|
||
const shaderIndex = +variant.shader.index;
|
||
const isVariantSelected = this.currentShaderIndex === shaderIndex;
|
||
const isActive = variant.shader.active;
|
||
if (isVariantSelected) {
|
||
divClass += ' selected';
|
||
}
|
||
if (!isActive && !isMatinfo) {
|
||
divClass += ' inactive';
|
||
}
|
||
const onClickVariant = this._handleVariantClick.bind(this, shaderIndex);
|
||
// Handle the case where variantString is empty (default variant?)
|
||
let vstring = (variant.shader.variantString || '').trim();
|
||
if (vstring.length > 0) {
|
||
vstring = `[${vstring}]`;
|
||
}
|
||
const shaderModelSymbol = {
|
||
'desktop': '(desktop)',
|
||
'mobile': '(mobile)',
|
||
};
|
||
let languagesDiv = isVariantSelected ? this._buildLanguagesDiv(isActive) : null;
|
||
const stage = (variant.shader.pipelineStage.indexOf('vertex') >=0 ? '𝕍ert' : '𝔽rag');
|
||
const shaderModel = (isMatinfo ? shaderModelSymbol[variant.shader.shaderModel] : '');
|
||
return html`
|
||
<div class="${divClass}" @click="${onClickVariant}">
|
||
<div>${stage} ${vstring} ${shaderModel} </div>
|
||
</div>
|
||
${languagesDiv ?? nothing}
|
||
`
|
||
});
|
||
return html`<div class="variant-list">${variants}</div>`;
|
||
}
|
||
|
||
render() {
|
||
const isMatinfo = this.currentShaderModel == 'matinfo';
|
||
const sections = (title, domain) => {
|
||
const mats = this.materials
|
||
.filter((m) => m.domain == domain)
|
||
.filter((mat) => {
|
||
const material = this.database[mat.matid];
|
||
return !this.hideInactiveVariants || material.active;
|
||
})
|
||
.map((mat) => {
|
||
const material = this.database[mat.matid];
|
||
const onClick = this._handleMaterialClick.bind(this, mat.matid);
|
||
let divClass = 'material_variant_language';
|
||
let shaderDiv = null;
|
||
const isMaterialSelected = mat.matid === this.currentMaterial;
|
||
if (isMaterialSelected) {
|
||
divClass += ' selected';
|
||
shaderDiv = this._buildShaderDiv();
|
||
}
|
||
if (!material.active && !isMatinfo) {
|
||
divClass += " inactive";
|
||
}
|
||
const matName = mat.name;
|
||
return html`
|
||
<div class="${divClass}" @click="${onClick}" data-id="${mat.matid}">
|
||
${matName}
|
||
</div>
|
||
${shaderDiv ?? nothing}
|
||
`;
|
||
});
|
||
if (mats.length > 0) {
|
||
return html`<menu-section title="${title}">${mats}</menu-section>`;
|
||
}
|
||
return null;
|
||
};
|
||
const advancedOptions =
|
||
(() => html`
|
||
<advanced-options id="advanced-options"
|
||
current-backend=${this.currentBackend}
|
||
current-shader-model=${this.currentShaderModel}
|
||
?hide-inactive-variants=${this.hideInactiveVariants}
|
||
></advanced-options>
|
||
`)();
|
||
|
||
return html`
|
||
<style>${this.dynamicStyle()}</style>
|
||
<div class="container">
|
||
<div class="title">${isMatinfo ? 'matinfo' : 'matdbg'}</div>
|
||
${sections("Surface", "surface") ?? nothing}
|
||
${sections("Post-processing", "postpro") ?? nothing}
|
||
<material-info id="material-info"></material-info>
|
||
${advancedOptions ?? nothing}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
}
|
||
customElements.define("material-sidepanel", MaterialSidePanel);
|
||
|
||
class MatdbgViewer extends LitElement {
|
||
static get styles() {
|
||
return css`
|
||
:host {
|
||
height: 100%;
|
||
width: 100%;
|
||
display: flex;
|
||
}
|
||
`;
|
||
}
|
||
|
||
get _sidepanel() {
|
||
return this.renderRoot.querySelector('#sidepanel');
|
||
}
|
||
|
||
get _codeviewer() {
|
||
return this.renderRoot.querySelector('#code-viewer');
|
||
}
|
||
|
||
async init() {
|
||
const isConnected = () => this.connected;
|
||
statusLoop(
|
||
isConnected,
|
||
async (status, data) => {
|
||
this.connected = status == STATUS_CONNECTED || status == STATUS_MATERIAL_UPDATED;
|
||
|
||
if (status == STATUS_MATERIAL_UPDATED) {
|
||
let matInfo = await fetchMaterial(matid);
|
||
this.database[matInfo.matid] = matInfo;
|
||
this.database = this.database;
|
||
}
|
||
}
|
||
);
|
||
|
||
activeShadersLoop(
|
||
isConnected,
|
||
(activeShaders) => {
|
||
this.activeShaders = activeShaders;
|
||
}
|
||
);
|
||
|
||
this.database = await fetchMaterials();
|
||
|
||
// This is the user preferences stored in localStorage
|
||
let hideInactiveVariantsVal = localStorage.getItem('option-hide-inactive-variants');
|
||
if (hideInactiveVariantsVal != null) {
|
||
this.hideInactiveVariants = hideInactiveVariantsVal == 'true';
|
||
}
|
||
}
|
||
|
||
_getShader() {
|
||
if (!this.currentLanguage || this.currentShaderIndex < 0 || !this.currentBackend) {
|
||
return null;
|
||
}
|
||
const material = (this.database && this.currentMaterial) ? this.database[this.currentMaterial] : null;
|
||
if (!material) {
|
||
return null;
|
||
}
|
||
const shaders = material[this.currentBackend];
|
||
return shaders[this.currentShaderIndex];
|
||
}
|
||
|
||
_onResize() {
|
||
const rect = this._sidepanel.getBoundingClientRect();
|
||
this.codeViewerExpectedWidth = window.innerWidth - rect.width - 1;
|
||
this.codeViewerExpectedHeight = window.innerHeight;
|
||
}
|
||
|
||
firstUpdated() {
|
||
this._onResize();
|
||
}
|
||
|
||
constructor() {
|
||
super();
|
||
this.connected = false;
|
||
this.activeShaders = {};
|
||
this.database = {};
|
||
this.currentShaderIndex = -1;
|
||
this.currentMaterial = null;
|
||
this.currentLanguage = null;
|
||
this.currentBackend = null;
|
||
this.currentShaderModel = null;
|
||
this.hideInactiveVariants = false;
|
||
this.init();
|
||
|
||
this.addEventListener('select-material',
|
||
(ev) => {
|
||
this.currentMaterial = ev.detail;
|
||
}
|
||
);
|
||
this.addEventListener('select-variant',
|
||
(ev) => {
|
||
this.currentShaderIndex = ev.detail;
|
||
}
|
||
);
|
||
this.addEventListener('select-language',
|
||
(ev) => {
|
||
this.currentLanguage = ev.detail;
|
||
}
|
||
);
|
||
|
||
this.addEventListener('rebuild-shader',
|
||
(ev) => {
|
||
const shader = this._getShader();
|
||
if (!shader) {
|
||
return
|
||
}
|
||
rebuildMaterial(
|
||
this.currentMaterial, this.currentBackend, this.currentShaderIndex, ev.detail);
|
||
|
||
shader.modified = false;
|
||
// Trigger an update
|
||
this.database = this.database;
|
||
}
|
||
);
|
||
|
||
this.addEventListener('shader-edited',
|
||
(ev) => {
|
||
const shader = this._getShader();
|
||
if (shader) {
|
||
shader.modified = true;
|
||
// Trigger an update
|
||
this.database = this.database;
|
||
}
|
||
}
|
||
);
|
||
|
||
this.addEventListener('option-backend',
|
||
(ev) => {
|
||
this.currentBackend = ev.detail;
|
||
}
|
||
);
|
||
|
||
this.addEventListener('option-hide-inactive-variants',
|
||
(ev) => {
|
||
this.hideInactiveVariants = !this.hideInactiveVariants;
|
||
localStorage.setItem('option-hide-inactive-variants', "" + this.hideInactiveVariants);
|
||
}
|
||
);
|
||
addEventListener('resize', this._onResize.bind(this));
|
||
}
|
||
|
||
static get properties() {
|
||
return {
|
||
connected: {type: Boolean, state: true},
|
||
database: {type: Object, state: true},
|
||
activeShaders: {type: Object, state: true},
|
||
currentLanguage: {type: String, state: true},
|
||
currentMaterial: {type: String, state: true},
|
||
// Each material has a list of variants compiled for it, this index tracks a position in the list.
|
||
currentShaderIndex: {type: Number, state: true},
|
||
currentBackend: {type: String, state: true},
|
||
currentShaderModel: {type: String, state: true},
|
||
codeViewerExpectedWidth: {type: Number, state: true},
|
||
codeViewerExpectedHeight: {type: Number, state: true},
|
||
|
||
hideInactiveVariants: {type: Boolean, state: true},
|
||
}
|
||
}
|
||
|
||
updated(props) {
|
||
if (props.has('currentShaderModel')) {
|
||
if (this.currentShaderModel == 'matinfo') {
|
||
this.hideInactiveVariants = false;
|
||
}
|
||
}
|
||
// Set a language if there hasn't been one set.
|
||
if (props.has('currentBackend') && this.currentBackend) {
|
||
if (LANGUAGE_CHOICES[this.currentBackend].indexOf(this.currentLanguage) < 0) {
|
||
this.currentLanguage = DEFAULT_LANGUAGE_FOR_BACKEND[this.currentBackend];
|
||
}
|
||
}
|
||
if (props.has('currentMaterial')) {
|
||
// Try to find a default shader index
|
||
if ((this.currentMaterial in this.activeShaders) && this.currentBackend) {
|
||
const material = this.database[this.currentMaterial];
|
||
const activeVariants = this.activeShaders[this.currentMaterial].variants;
|
||
const materialShaders = material[this.currentBackend];
|
||
for (let shader in materialShaders) {
|
||
const shaderVariant = materialShaders[shader].variant;
|
||
if (activeVariants.indexOf(shaderVariant) >= 0) {
|
||
this.currentShaderIndex = +shader;
|
||
break;
|
||
}
|
||
}
|
||
} else if (this.currentMaterial) {
|
||
const material = this.database[this.currentMaterial];
|
||
// Just pick the first variant in this materials list.
|
||
this.currentShaderIndex = 0;
|
||
}
|
||
}
|
||
if ((props.has('currentMaterial') || props.has('currentBackend') ||
|
||
props.has('currentShaderIndex') || props.has('currentLanguage')) &&
|
||
(this.currentMaterial && this.currentBackend && this.currentShaderIndex >= 0&&
|
||
this.currentLanguage)) {
|
||
(async () => {
|
||
this._codeviewer.code = await fetchShaderCode(
|
||
this.currentMaterial, this.currentBackend, this.currentLanguage,
|
||
this.currentShaderIndex);
|
||
const shader = this._getShader();
|
||
if (shader) {
|
||
shader.modified = false;
|
||
this.database = this.database;
|
||
}
|
||
// Size of the editor will be adjusted due to the code being loaded, we try to
|
||
// fit the editor again by calling the resize signal.
|
||
setTimeout(this._onResize.bind(this), 700);
|
||
})();
|
||
}
|
||
if (props.has('activeShaders') || props.has('database')) {
|
||
// The only active materials are the ones with active variants.
|
||
Object.values(this.database).forEach((material) => {
|
||
material.active = false;
|
||
});
|
||
for (matid in this.activeShaders) {
|
||
if (!this.database[matid]) {
|
||
continue;
|
||
}
|
||
let material = this.database[matid];
|
||
const backend = this.activeShaders[matid].backend;
|
||
const variants = this.activeShaders[matid].variants;
|
||
for (let shader of material[backend]) {
|
||
shader.active = variants.indexOf(shader.variant) > -1;
|
||
material.active = material.active || shader.active;
|
||
}
|
||
}
|
||
if (_validDict(this.activeShaders)) {
|
||
let setBackend = null;
|
||
let setShaderModel = null;
|
||
for (let matid in this.activeShaders) {
|
||
const backend = this.activeShaders[matid].backend;
|
||
if (!setBackend && backend) {
|
||
setBackend = backend;
|
||
}
|
||
const shaderModel = this.activeShaders[matid].shaderModel;
|
||
if (!setShaderModel && shaderModel) {
|
||
setShaderModel = shaderModel;
|
||
}
|
||
}
|
||
if (this.currentBackend != setBackend) {
|
||
this.currentBackend = setBackend;
|
||
}
|
||
if (this.currentShaderModel != setShaderModel) {
|
||
this.currentShaderModel = setShaderModel;
|
||
}
|
||
|
||
} else if (!this.currentBackend || !this.currentShaderModel) {
|
||
// Make a guess on the backend if one wasn't from activeShaders.
|
||
this.currentBackend = guessBackend();
|
||
this.currentShaderModel = 'matinfo';
|
||
}
|
||
|
||
this._sidepanel.database = this.database;
|
||
this._sidepanel.activeShaders = this.activeShaders;
|
||
}
|
||
if (props.has('connected') && this.connected) {
|
||
(async () => {
|
||
for (const matId of await fetchMatIds()) {
|
||
const matInfo = await fetchMaterial(matid);
|
||
this.database[matInfo.matid] = matInfo;
|
||
this.database = this.database;
|
||
}
|
||
|
||
// In the `matinfo -w` usecase, we assume the current material to be the only
|
||
// material available in the database.
|
||
if (_isMatInfoMode(this.database)) {
|
||
this.currentMaterial = Object.keys(this.database)[0];
|
||
}
|
||
})();
|
||
}
|
||
}
|
||
|
||
render() {
|
||
const shader = this._getShader();
|
||
return html`
|
||
<material-sidepanel id="sidepanel"
|
||
?connected="${this.connected}"
|
||
current-language="${this.currentLanguage}"
|
||
current-shader-index="${this.currentShaderIndex}"
|
||
current-material="${this.currentMaterial}"
|
||
current-backend="${this.currentBackend}"
|
||
current-shader-model="${this.currentShaderModel}"
|
||
?hide-inactive-variants="${this.hideInactiveVariants}" >
|
||
</material-sidepanel>
|
||
<code-viewer id="code-viewer"
|
||
?active=${shader && shader.active}
|
||
?modified=${shader && shader.modified}
|
||
?connected="${this.connected}"
|
||
expected-width="${this.codeViewerExpectedWidth}"
|
||
expected-height="${this.codeViewerExpectedHeight}" >
|
||
</code-viewer>
|
||
`;
|
||
}
|
||
}
|
||
customElements.define("matdbg-viewer", MatdbgViewer);
|