mirror of
https://github.com/storytold/storyteller-bevy.git
synced 2026-10-29 09:08:58 +00:00
Vendored
+1
-4
@@ -4,10 +4,7 @@
|
|||||||
"*.element.ts": "${capture}.element.scss, ${capture}.element.spec.ts",
|
"*.element.ts": "${capture}.element.scss, ${capture}.element.spec.ts",
|
||||||
"*.tsx": "${capture}.module.scss, ${capture}.spec.tsx",
|
"*.tsx": "${capture}.module.scss, ${capture}.spec.tsx",
|
||||||
},
|
},
|
||||||
"rust-analyzer.cargo.features": [
|
"rust-analyzer.cargo.features": ["wasm"],
|
||||||
"wasm",
|
|
||||||
"wasm_screenshot"
|
|
||||||
],
|
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.meta": "ron"
|
"*.meta": "ron"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
{
|
{
|
||||||
"projects": ["studio"],
|
"projects": ["studio"],
|
||||||
"target": "build:image-generator"
|
"target": "build:web"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"options": {
|
"options": {
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
{
|
{
|
||||||
"projects": ["studio"],
|
"projects": ["studio"],
|
||||||
"target": "build:image-generator"
|
"target": "build:web"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"options": {
|
"options": {
|
||||||
|
|||||||
@@ -27,10 +27,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 1rem {
|
border-inline-end: none;
|
||||||
inline-end: 0.5rem;
|
|
||||||
}
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.hierarchy-resize-handle {
|
.hierarchy-resize-handle {
|
||||||
@@ -73,8 +70,9 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-prompt {
|
.config-prompts {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +116,6 @@
|
|||||||
color: #FFFF;
|
color: #FFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.prompt {
|
|
||||||
width: 512px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.seed {
|
&.seed {
|
||||||
width: 96px;
|
width: 96px;
|
||||||
appearance: textfield;
|
appearance: textfield;
|
||||||
@@ -134,8 +128,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.config-field-group {
|
.config-field-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
.pre-icon {
|
||||||
|
&.red {
|
||||||
|
color: rgb(220, 53, 69);
|
||||||
|
font-size: 1.333rem;
|
||||||
|
}
|
||||||
|
&.green {
|
||||||
|
color: rgb(25, 135, 84);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-row {
|
||||||
|
display: flex;
|
||||||
|
width: 768px;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seed-refresh {
|
.seed-refresh {
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
StudioMode,
|
StudioMode,
|
||||||
} from "@storyteller/studio";
|
} from "@storyteller/studio";
|
||||||
import { match } from "@storyteller/utility";
|
import { match } from "@storyteller/utility";
|
||||||
import { LitElement, html, nothing } from "lit";
|
import { LitElement, type PropertyValues, html, nothing } from "lit";
|
||||||
import { customElement, state } from "lit/decorators.js";
|
import { customElement, state } from "lit/decorators.js";
|
||||||
import { styleMap } from "lit/directives/style-map.js";
|
|
||||||
import { classMap } from "lit/directives/class-map.js";
|
import { classMap } from "lit/directives/class-map.js";
|
||||||
|
import { createRef, ref } from "lit/directives/ref.js";
|
||||||
|
import { styleMap } from "lit/directives/style-map.js";
|
||||||
|
|
||||||
|
import { ImageInfo, LcmClient } from "./lcm-client";
|
||||||
|
|
||||||
import "@storyteller/studio-web/canvas-provider";
|
import "@storyteller/studio-web/canvas-provider";
|
||||||
import "@storyteller/studio-web/scene-hierarchy";
|
import "@storyteller/studio-web/scene-hierarchy";
|
||||||
@@ -21,26 +24,81 @@ import "./app.element.scss"
|
|||||||
|
|
||||||
@customElement("app-root")
|
@customElement("app-root")
|
||||||
export class AppElement extends LitElement {
|
export class AppElement extends LitElement {
|
||||||
@state() imageCaptureUrl?: string;
|
|
||||||
@state() realtimeCapture = true;
|
|
||||||
|
|
||||||
@state() hierarchyWidth = 256;
|
@state() hierarchyWidth = 256;
|
||||||
@state() resizing = false;
|
@state() resizing = false;
|
||||||
|
|
||||||
@state() loading = true;
|
@state() loading = true;
|
||||||
|
|
||||||
|
@state() blob?: Blob;
|
||||||
@state() prompt = "";
|
@state() prompt = "";
|
||||||
|
@state() negPrompt = "";
|
||||||
@state() seed = randomSeed();
|
@state() seed = randomSeed();
|
||||||
|
|
||||||
|
@state() inputUrl?: string;
|
||||||
|
@state() outputUrl?: string;
|
||||||
|
|
||||||
|
#lcmClient = new LcmClient(crypto.randomUUID());
|
||||||
|
#outputImageRef = createRef<HTMLImageElement>();
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.#lcmClient.addEventListener("lcm-image-ready", event => {
|
||||||
|
console.log("lcm-image-ready", event.detail);
|
||||||
|
|
||||||
|
const prevUrl = this.outputUrl;
|
||||||
|
this.outputUrl = URL.createObjectURL(event.detail);
|
||||||
|
|
||||||
|
if (prevUrl)
|
||||||
|
URL.revokeObjectURL(prevUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#lcmClient.addEventListener("lcm-image-url-ready", event => {
|
||||||
|
console.log("lcm-image-url-ready", event.detail);
|
||||||
|
this.outputUrl = event.detail;
|
||||||
|
this.requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected override createRenderRoot = () => this;
|
protected override createRenderRoot = () => this;
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
if (this.imageCaptureUrl != null)
|
if (this.inputUrl)
|
||||||
URL.revokeObjectURL(this.imageCaptureUrl);
|
URL.revokeObjectURL(this.inputUrl);
|
||||||
|
|
||||||
|
if (this.outputUrl)
|
||||||
|
URL.revokeObjectURL(this.outputUrl);
|
||||||
|
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override willUpdate(changes: PropertyValues<this>): void {
|
||||||
|
let updates: Partial<ImageInfo> = {};
|
||||||
|
|
||||||
|
if (changes.has("blob"))
|
||||||
|
updates.blob = this.blob;
|
||||||
|
|
||||||
|
if (changes.has("prompt"))
|
||||||
|
updates.prompt = this.prompt;
|
||||||
|
|
||||||
|
if (changes.has("negPrompt"))
|
||||||
|
updates.negativePrompt = this.negPrompt;
|
||||||
|
|
||||||
|
if (changes.has("seed"))
|
||||||
|
updates.seed = this.seed;
|
||||||
|
|
||||||
|
if (
|
||||||
|
changes.has("blob")
|
||||||
|
|| changes.has("prompt")
|
||||||
|
|| changes.has("negPrompt")
|
||||||
|
|| changes.has("seed")
|
||||||
|
) {
|
||||||
|
this.#lcmClient.update(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
super.willUpdate(changes);
|
||||||
|
}
|
||||||
|
|
||||||
@on("scene-state")
|
@on("scene-state")
|
||||||
onSceneStateChange({ detail: state }: SceneStateEvent): void {
|
onSceneStateChange({ detail: state }: SceneStateEvent): void {
|
||||||
console.log("scene state change:", SceneState[state]);
|
console.log("scene state change:", SceneState[state]);
|
||||||
@@ -52,12 +110,9 @@ export class AppElement extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@on("entity-changes")
|
@on("entity-changes")
|
||||||
|
@debounce(1000 / 24)
|
||||||
onStudioChanges(): void {
|
onStudioChanges(): void {
|
||||||
if (this.realtimeCapture) {
|
studio.requestScreenshot();
|
||||||
this.requestScreenshot_realtime();
|
|
||||||
} else {
|
|
||||||
this.requestScreenshot_delayed();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@on("viewport-resized")
|
@on("viewport-resized")
|
||||||
@@ -67,27 +122,40 @@ export class AppElement extends LitElement {
|
|||||||
studio.requestScreenshot();
|
studio.requestScreenshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@debounce(1000 / 24, { maxWait: 1000 / 5 })
|
|
||||||
requestScreenshot_realtime() {
|
|
||||||
studio.requestScreenshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
@debounce(1000 / 24)
|
|
||||||
requestScreenshot_delayed() {
|
|
||||||
studio.requestScreenshot();
|
|
||||||
}
|
|
||||||
|
|
||||||
@on("screenshot-ready")
|
@on("screenshot-ready")
|
||||||
onScreenshotReady({ detail: screenshot }: ScreenshotReadyEvent) {
|
onScreenshotReady({ detail: screenshot }: ScreenshotReadyEvent) {
|
||||||
const prevUrl = this.imageCaptureUrl;
|
this.blob = new Blob([screenshot.data.buffer], { type: "image/jpg" });
|
||||||
|
|
||||||
const blob = new Blob([screenshot.data.buffer], { type: "image/png" });
|
const prevUrl = this.inputUrl;
|
||||||
this.imageCaptureUrl = URL.createObjectURL(blob);
|
this.inputUrl = URL.createObjectURL(this.blob);
|
||||||
|
|
||||||
if (prevUrl != null)
|
if (prevUrl != null)
|
||||||
URL.revokeObjectURL(prevUrl);
|
URL.revokeObjectURL(prevUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePrompt(event: Event): void {
|
||||||
|
this.prompt = (event.target as HTMLInputElement).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNegPrompt(event: Event): void {
|
||||||
|
this.negPrompt = (event.target as HTMLInputElement).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSeed(): void {
|
||||||
|
this.seed = randomSeed();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTextInputBubbling(event: KeyboardEvent): void {
|
||||||
|
// We want modifier keys like "Alt" to always be received by the Bevy
|
||||||
|
// canvas because they affect the camera controller behavior, but we do
|
||||||
|
// NOT want Bevy to receive typing inputs like W, A, S, and D while the
|
||||||
|
// user is trying to type into the Prompt field.
|
||||||
|
if (/^(Alt|Control)$/.test(event.key))
|
||||||
|
return;
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
onResizeStart(): void {
|
onResizeStart(): void {
|
||||||
this.resizing = true;
|
this.resizing = true;
|
||||||
}
|
}
|
||||||
@@ -101,15 +169,6 @@ export class AppElement extends LitElement {
|
|||||||
this.hierarchyWidth = Math.max(Math.min(updated, 512), 128);
|
this.hierarchyWidth = Math.max(Math.min(updated, 512), 128);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleRealtimeCapture(event: InputEvent): void {
|
|
||||||
const { checked } = event.target as HTMLInputElement;
|
|
||||||
this.realtimeCapture = checked;
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshSeed(): void {
|
|
||||||
this.seed = randomSeed();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override render = () => html`
|
protected override render = () => html`
|
||||||
<sts-canvas-provider class="app-root">
|
<sts-canvas-provider class="app-root">
|
||||||
<div
|
<div
|
||||||
@@ -145,45 +204,81 @@ export class AppElement extends LitElement {
|
|||||||
></sts-transform-toolbar>
|
></sts-transform-toolbar>
|
||||||
</sts-studio>
|
</sts-studio>
|
||||||
|
|
||||||
${this.imageCaptureUrl != null ? html`
|
${this.inputUrl || this.outputUrl ? html`
|
||||||
<img
|
<img
|
||||||
|
${ref(this.#outputImageRef)}
|
||||||
class="image-capture"
|
class="image-capture"
|
||||||
src=${this.imageCaptureUrl}
|
src=${(this.outputUrl ?? this.inputUrl)!}
|
||||||
alt="Image capture"
|
alt="Image capture"
|
||||||
/>
|
/>
|
||||||
` : html`
|
` : html`
|
||||||
<div class="image-capture placeholder"></div>
|
<div class="image-capture placeholder"></div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
<div class="config config-prompt h-center v-end"
|
<div class="config config-prompts h-center v-end"
|
||||||
@keydown=${(event: KeyboardEvent) => event.stopPropagation()}
|
@keydown=${this.stopTextInputBubbling}
|
||||||
@keyup=${(event: KeyboardEvent) => event.stopPropagation()}
|
@keyup=${this.stopTextInputBubbling}
|
||||||
>
|
>
|
||||||
<input
|
<div class="config-field-group prompt">
|
||||||
class="config-field prompt"
|
<sts-icon class="pre-icon green" icon="check"></sts-icon>
|
||||||
type="text"
|
|
||||||
aria-label="Prompt"
|
|
||||||
placeholder="Enter a prompt to customize the output"
|
|
||||||
/>
|
|
||||||
<div class="config-field-group">
|
|
||||||
<input
|
<input
|
||||||
class="config-field seed"
|
class="config-field prompt"
|
||||||
type="number"
|
type="text"
|
||||||
aria-label="Seed"
|
aria-label="Affirmative Prompt"
|
||||||
value=${this.seed}
|
placeholder="Enter an affirmative prompt to customize the output"
|
||||||
min="100"
|
.value=${this.prompt}
|
||||||
max="10000"
|
@change=${this.updatePrompt}
|
||||||
|
@keydown=${(event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.updatePrompt(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
</div>
|
||||||
class="seed-refresh"
|
<div class="config-row">
|
||||||
@click=${this.refreshSeed}
|
<div class="config-field-group prompt">
|
||||||
>
|
<sts-icon class="pre-icon red" icon="xmark"></sts-icon>
|
||||||
<sts-icon icon="rotate"></sts-icon>
|
<input
|
||||||
</button>
|
class="config-field prompt"
|
||||||
|
type="text"
|
||||||
|
aria-label="Negative Prompt"
|
||||||
|
placeholder="Enter a negative prompt to customize the output"
|
||||||
|
.value=${this.negPrompt}
|
||||||
|
@change=${this.updateNegPrompt}
|
||||||
|
@keydown=${(event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.updateNegPrompt(event);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="config-field-group">
|
||||||
|
<input
|
||||||
|
class="config-field seed"
|
||||||
|
type="number"
|
||||||
|
aria-label="Seed"
|
||||||
|
.value=${this.seed.toString()}
|
||||||
|
min="1"
|
||||||
|
max="99999"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="seed-refresh"
|
||||||
|
@click=${this.refreshSeed}
|
||||||
|
>
|
||||||
|
<sts-icon icon="rotate"></sts-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${/*
|
||||||
|
FIXME: Real-time capture is not really feasible until we can come up
|
||||||
|
with a more elegant way to exclude the world grid, gizmos, etc.
|
||||||
|
from the captured image
|
||||||
|
|
||||||
<div class="config v-start h-end">
|
<div class="config v-start h-end">
|
||||||
<label class="config-checkbox">
|
<label class="config-checkbox">
|
||||||
<input
|
<input
|
||||||
@@ -194,6 +289,8 @@ export class AppElement extends LitElement {
|
|||||||
Realtime Capture
|
Realtime Capture
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
*/
|
||||||
|
nothing}
|
||||||
|
|
||||||
${this.loading ? html`
|
${this.loading ? html`
|
||||||
<!-- TODO: Animated spinner or something -- talk to our designers -->
|
<!-- TODO: Animated spinner or something -- talk to our designers -->
|
||||||
@@ -206,5 +303,5 @@ export class AppElement extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function randomSeed(): number {
|
function randomSeed(): number {
|
||||||
return Math.round(Math.random() * 5000);
|
return Math.round(Math.random() * 99999);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
export enum LcmLiveStatus {
|
||||||
|
None = "NONE",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
Wait = "wait",
|
||||||
|
SendFrame = "send_frame",
|
||||||
|
Timeout = "timeout",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageInfo {
|
||||||
|
blob: Blob;
|
||||||
|
prompt: string;
|
||||||
|
negativePrompt: string;
|
||||||
|
seed: number;
|
||||||
|
guidanceScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LcmMessageData {
|
||||||
|
status: LcmLiveStatus | "error";
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LcmClient extends WebSocket {
|
||||||
|
static readonly Api = "//b3000.pancake-chimaera.ts.net/api";
|
||||||
|
|
||||||
|
#userId: string;
|
||||||
|
|
||||||
|
get status() { return this.#status; }
|
||||||
|
#status = LcmLiveStatus.None;
|
||||||
|
|
||||||
|
#waiting = false;
|
||||||
|
|
||||||
|
#blob?: Blob;
|
||||||
|
#prompt?: string;
|
||||||
|
#negativePrompt?: string;
|
||||||
|
#seed?: number;
|
||||||
|
#guidanceScale = 0.8;
|
||||||
|
|
||||||
|
constructor (userId: string) {
|
||||||
|
super(`wss:${LcmClient.Api}/ws/${userId}`);
|
||||||
|
this.#userId = userId;
|
||||||
|
|
||||||
|
this.addEventListener("open", () => this.#onOpen());
|
||||||
|
this.addEventListener("close", () => this.#onClose());
|
||||||
|
this.addEventListener("message", event => this.#onMessage(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
update(data: Partial<ImageInfo>): void {
|
||||||
|
if ("blob" in data)
|
||||||
|
this.#blob = data.blob;
|
||||||
|
|
||||||
|
if ("prompt" in data)
|
||||||
|
this.#prompt = data.prompt;
|
||||||
|
|
||||||
|
if ("negativePrompt" in data)
|
||||||
|
this.#negativePrompt = data.negativePrompt;
|
||||||
|
|
||||||
|
if ("seed" in data)
|
||||||
|
this.#seed = data.seed;
|
||||||
|
|
||||||
|
this.#update();
|
||||||
|
}
|
||||||
|
|
||||||
|
#onOpen(): void {
|
||||||
|
console.log(`Marking connection to websocket as open for user ${this.#userId}`);
|
||||||
|
this.#status = LcmLiveStatus.Connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
#onClose(): void {
|
||||||
|
console.log(`Marking connection to websocket as closed for user ${this.#userId}`);
|
||||||
|
this.#status = LcmLiveStatus.Disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
#onMessage(event: MessageEvent<string>): void {
|
||||||
|
console.log("onMessage:", event);
|
||||||
|
const data: LcmMessageData = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.status === "error") {
|
||||||
|
this.#status = LcmLiveStatus.Disconnected;
|
||||||
|
console.error(data.message);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#status = data.status;
|
||||||
|
|
||||||
|
if (this.#status === LcmLiveStatus.SendFrame && this.#waiting)
|
||||||
|
this.#sendFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
#update(): void {
|
||||||
|
if (this.#status === LcmLiveStatus.SendFrame) {
|
||||||
|
this.#sendFrame();
|
||||||
|
} else {
|
||||||
|
this.#waiting = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #sendFrame(): Promise<void> {
|
||||||
|
if (!this.#blob || !this.#prompt || !this.#negativePrompt || this.#seed == null) {
|
||||||
|
this.#waiting = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Sending next frame...");
|
||||||
|
|
||||||
|
this.send(JSON.stringify({ status: "next_frame" }));
|
||||||
|
this.send(JSON.stringify({
|
||||||
|
prompt: this.#prompt,
|
||||||
|
negativePrompt: this.#negativePrompt,
|
||||||
|
seed: this.#seed,
|
||||||
|
guidanceScale: this.#guidanceScale,
|
||||||
|
}));
|
||||||
|
this.send(this.#blob);
|
||||||
|
|
||||||
|
this.#waiting = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https:${LcmClient.Api}/stream/${this.#userId}`;
|
||||||
|
|
||||||
|
// FIXME: This only actually renders an image in the DOM about ~10% of the time
|
||||||
|
this.dispatchEvent(new CustomEvent("lcm-image-url-ready", { detail: url }));
|
||||||
|
|
||||||
|
// const response = await fetch(url, { method: "GET" });
|
||||||
|
// console.log("response:", response);
|
||||||
|
|
||||||
|
// if (!response.ok || !response.body) {
|
||||||
|
// console.error(`GET failed with status ${response.status}: "${response.statusText}"`);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const blobParts = [] as Uint8Array[];
|
||||||
|
// const reader = response.body.getReader();
|
||||||
|
|
||||||
|
// // FIXME: This doesn't appear to be an image
|
||||||
|
// // const { value } = await reader.read();
|
||||||
|
// // if (value) blobParts.push(value);
|
||||||
|
|
||||||
|
// // FIXME: this just stops responding after the first `read()` call even
|
||||||
|
// // though `done` is false
|
||||||
|
// while (true) {
|
||||||
|
// console.log("Reading next chunk...");
|
||||||
|
//
|
||||||
|
// const { value, done } = await reader.read();
|
||||||
|
// console.log({ value, done });
|
||||||
|
//
|
||||||
|
// if (value) blobParts.push(value);
|
||||||
|
// if (done) break;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// this.dispatchEvent(new CustomEvent<Blob>("lcm-image-ready", {
|
||||||
|
// detail: new Blob(blobParts, { type: "image/jpg" }),
|
||||||
|
// }));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LcmClientEventMap extends WebSocketEventMap {
|
||||||
|
"lcm-image-ready": CustomEvent<Blob>;
|
||||||
|
"lcm-image-url-ready": CustomEvent<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LcmClient {
|
||||||
|
addEventListener<K extends keyof LcmClientEventMap>(
|
||||||
|
type: K,
|
||||||
|
listener: (this: LcmClient, ev: LcmClientEventMap[K]) => any,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): void;
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ import fasFloppyDisk from "@fortawesome/fontawesome-pro/svgs/solid/floppy-disk.s
|
|||||||
import farSkeleton from "@fortawesome/fontawesome-pro/svgs/regular/skeleton.svg?raw";
|
import farSkeleton from "@fortawesome/fontawesome-pro/svgs/regular/skeleton.svg?raw";
|
||||||
import farBone from "@fortawesome/fontawesome-pro/svgs/regular/bone.svg?raw";
|
import farBone from "@fortawesome/fontawesome-pro/svgs/regular/bone.svg?raw";
|
||||||
import fasBrightness from "@fortawesome/fontawesome-pro/svgs/solid/brightness.svg?raw";
|
import fasBrightness from "@fortawesome/fontawesome-pro/svgs/solid/brightness.svg?raw";
|
||||||
|
import fasCheck from "@fortawesome/fontawesome-pro/svgs/solid/check.svg?raw";
|
||||||
|
import fasXmark from "@fortawesome/fontawesome-pro/svgs/solid/xmark.svg?raw";
|
||||||
|
import fasCircleCheck from "@fortawesome/fontawesome-pro/svgs/solid/circle-check.svg?raw";
|
||||||
|
import fasCircleXmark from "@fortawesome/fontawesome-pro/svgs/solid/circle-xmark.svg?raw";
|
||||||
|
|
||||||
import styles from "./icon.element.scss?inline";
|
import styles from "./icon.element.scss?inline";
|
||||||
|
|
||||||
@@ -53,7 +57,10 @@ function renderSvg(iconId: string) {
|
|||||||
"bone": () => farBone,
|
"bone": () => farBone,
|
||||||
"bwd-fast": () => fasBwdFast,
|
"bwd-fast": () => fasBwdFast,
|
||||||
"bwd-step": () => fasBwdStep,
|
"bwd-step": () => fasBwdStep,
|
||||||
|
"check": () => fasCheck,
|
||||||
|
"circle-check": () => fasCircleCheck,
|
||||||
"circle-question": () => fasCircleQuestion,
|
"circle-question": () => fasCircleQuestion,
|
||||||
|
"circle-xmark": () => fasCircleXmark,
|
||||||
"caret-right": () => fasCaretRight,
|
"caret-right": () => fasCaretRight,
|
||||||
"crosshairs": () => farCrosshairs,
|
"crosshairs": () => farCrosshairs,
|
||||||
"cube": () => farCube,
|
"cube": () => farCube,
|
||||||
@@ -70,6 +77,7 @@ function renderSvg(iconId: string) {
|
|||||||
"skeleton": () => farSkeleton,
|
"skeleton": () => farSkeleton,
|
||||||
"up-down-left-right": () => farUpDownLeftRight,
|
"up-down-left-right": () => farUpDownLeftRight,
|
||||||
"video": () => farVideo,
|
"video": () => farVideo,
|
||||||
|
"xmark": () => fasXmark,
|
||||||
_: () => null,
|
_: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,9 @@
|
|||||||
background: #FFF3;
|
background: #FFF3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ wasm = [
|
|||||||
"bevy_rapier3d/wasm-bindgen",
|
"bevy_rapier3d/wasm-bindgen",
|
||||||
]
|
]
|
||||||
desktop = []
|
desktop = []
|
||||||
wasm_screenshot = []
|
|
||||||
|
|
||||||
[dependencies.bevy]
|
[dependencies.bevy]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -31,14 +31,6 @@
|
|||||||
"cwd": "studio"
|
"cwd": "studio"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"build:image-generator": {
|
|
||||||
"executor": "nx:run-commands",
|
|
||||||
"inputs": ["assets"],
|
|
||||||
"options": {
|
|
||||||
"command": "node scripts/build-web.mjs --features=wasm,wasm_screenshot",
|
|
||||||
"cwd": "studio"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dev": {
|
"dev": {
|
||||||
"executor": "@nxrs/cargo:run",
|
"executor": "@nxrs/cargo:run",
|
||||||
"options": {
|
"options": {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::scene::{SceneElement, SceneSpawnerSystem, SceneState};
|
||||||
|
|
||||||
pub struct ChangeTrackingPlugin;
|
pub struct ChangeTrackingPlugin;
|
||||||
|
|
||||||
impl Plugin for ChangeTrackingPlugin {
|
impl Plugin for ChangeTrackingPlugin {
|
||||||
@@ -8,6 +10,10 @@ impl Plugin for ChangeTrackingPlugin {
|
|||||||
|
|
||||||
// Add more of these systems for each component that we want to track
|
// Add more of these systems for each component that we want to track
|
||||||
app.add_systems(Update, send_change_events::<Transform>);
|
app.add_systems(Update, send_change_events::<Transform>);
|
||||||
|
app.add_systems(
|
||||||
|
OnEnter(SceneState::Active),
|
||||||
|
track_scene_elements.after(SceneSpawnerSystem),
|
||||||
|
);
|
||||||
|
|
||||||
#[cfg(feature = "wasm")]
|
#[cfg(feature = "wasm")]
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
@@ -29,6 +35,15 @@ fn send_change_events<T: Component>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn track_scene_elements(
|
||||||
|
mut cmd: Commands,
|
||||||
|
q_scene_elements: Query<Entity, (With<Transform>, With<SceneElement>, Without<ChangeTracker>)>,
|
||||||
|
) {
|
||||||
|
for element in q_scene_elements.iter() {
|
||||||
|
cmd.entity(element).insert(ChangeTracker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Event, Deref)]
|
#[derive(Event, Deref)]
|
||||||
pub struct EntityChangedEvent(pub Entity);
|
pub struct EntityChangedEvent(pub Entity);
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -177,11 +177,17 @@ fn translate_dcc_camera_events(
|
|||||||
else if (keyboard.just_released(KeyCode::AltLeft) && !keyboard.pressed(KeyCode::AltRight))
|
else if (keyboard.just_released(KeyCode::AltLeft) && !keyboard.pressed(KeyCode::AltRight))
|
||||||
|| (keyboard.just_released(KeyCode::AltRight) && !keyboard.pressed(KeyCode::AltLeft))
|
|| (keyboard.just_released(KeyCode::AltRight) && !keyboard.pressed(KeyCode::AltLeft))
|
||||||
{
|
{
|
||||||
input.send(InputAction(
|
// Stop Camera Orbit
|
||||||
Lifecycle::Stop,
|
if mouse_buttons.pressed(MouseButton::Left) {
|
||||||
CameraMotion::Orbit(Vec2::ZERO).into(),
|
input.send(InputAction(
|
||||||
));
|
Lifecycle::Stop,
|
||||||
input.send(InputAction(Lifecycle::Stop, CameraMotion::Zoom(0.).into()));
|
CameraMotion::Orbit(Vec2::ZERO).into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Stop Camera Zoom
|
||||||
|
else if mouse_buttons.pressed(MouseButton::Right) {
|
||||||
|
input.send(InputAction(Lifecycle::Stop, CameraMotion::Zoom(0.).into()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Camera Rotation
|
// Camera Rotation
|
||||||
else if mouse_buttons.pressed(MouseButton::Right) {
|
else if mouse_buttons.pressed(MouseButton::Right) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::interaction::TransformSpace;
|
|||||||
use super::material::GizmoMaterial;
|
use super::material::GizmoMaterial;
|
||||||
|
|
||||||
#[derive(Component, Reflect)]
|
#[derive(Component, Reflect)]
|
||||||
pub(super) struct TransformGizmo {
|
pub struct TransformGizmo {
|
||||||
pub(super) target: Entity,
|
pub(super) target: Entity,
|
||||||
pub(super) transform_space: TransformSpace,
|
pub(super) transform_space: TransformSpace,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use self::{
|
|||||||
rotation::{spawn_rotation_gizmo, update_ss_ring_rotation},
|
rotation::{spawn_rotation_gizmo, update_ss_ring_rotation},
|
||||||
translation::spawn_translation_gizmo,
|
translation::spawn_translation_gizmo,
|
||||||
};
|
};
|
||||||
|
pub use common::TransformGizmo;
|
||||||
|
|
||||||
use super::{Selection, TransformMode, TransformType};
|
use super::{Selection, TransformMode, TransformType};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -158,7 +158,7 @@ pub fn start(init: InitParams, mode: StudioMode, window_config: Option<Window>)
|
|||||||
..default()
|
..default()
|
||||||
}),
|
}),
|
||||||
CommonPlugins,
|
CommonPlugins,
|
||||||
#[cfg(all(feature = "wasm", feature = "wasm_screenshot"))]
|
#[cfg(feature = "wasm")]
|
||||||
wasm::ScreenshotPlugin,
|
wasm::ScreenshotPlugin,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|||||||
+3
-17
@@ -23,7 +23,6 @@ use wasm_bindgen::prelude::wasm_bindgen;
|
|||||||
use wgpu::{TextureDescriptor, TextureUsages};
|
use wgpu::{TextureDescriptor, TextureUsages};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
anim::SkeletalJoint,
|
|
||||||
camera_controller::{CameraController, CameraControllerBundle},
|
camera_controller::{CameraController, CameraControllerBundle},
|
||||||
change_tracking::{ChangeTracker, ChangeTrackingPlugin},
|
change_tracking::{ChangeTracker, ChangeTrackingPlugin},
|
||||||
debug::DebugPoint,
|
debug::DebugPoint,
|
||||||
@@ -63,13 +62,9 @@ impl Plugin for ScenePlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
OnEnter(SceneState::Active),
|
OnEnter(SceneState::Active),
|
||||||
(
|
(
|
||||||
(
|
tag_scene_elements,
|
||||||
tag_scene_elements,
|
frame_scene_in_view,
|
||||||
frame_scene_in_view,
|
start_capture.run_if(is_headless),
|
||||||
start_capture.run_if(is_headless),
|
|
||||||
),
|
|
||||||
apply_deferred,
|
|
||||||
track_scene_elements,
|
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(SceneSpawnerSystem),
|
.in_set(SceneSpawnerSystem),
|
||||||
@@ -524,15 +519,6 @@ fn start_capture(mut captures: Query<&mut ScreenCapture>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn track_scene_elements(
|
|
||||||
mut commands: Commands,
|
|
||||||
scene_elements: Query<Entity, (With<Transform>, With<SkeletalJoint>)>,
|
|
||||||
) {
|
|
||||||
for element in scene_elements.iter() {
|
|
||||||
commands.entity(element).insert(ChangeTracker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "wasm")]
|
#[cfg(feature = "wasm")]
|
||||||
mod wasm {
|
mod wasm {
|
||||||
use bevy::{prelude::*, window::PrimaryWindow};
|
use bevy::{prelude::*, window::PrimaryWindow};
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ use wasm_bindgen::prelude::wasm_bindgen;
|
|||||||
|
|
||||||
mod conv;
|
mod conv;
|
||||||
mod init;
|
mod init;
|
||||||
#[cfg(feature = "wasm_screenshot")]
|
|
||||||
mod screenshot;
|
mod screenshot;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub use conv::*;
|
pub use conv::*;
|
||||||
#[cfg(feature = "wasm_screenshot")]
|
|
||||||
pub use screenshot::ScreenshotPlugin;
|
pub use screenshot::ScreenshotPlugin;
|
||||||
pub(crate) use util::*;
|
pub(crate) use util::*;
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
//! to the frontend by converting the `Vec<u8>` to a [`js_sys::Uint8Array`]
|
//! to the frontend by converting the `Vec<u8>` to a [`js_sys::Uint8Array`]
|
||||||
|
|
||||||
use bevy::{prelude::*, render::view::screenshot::ScreenshotManager, window::PrimaryWindow};
|
use bevy::{prelude::*, render::view::screenshot::ScreenshotManager, window::PrimaryWindow};
|
||||||
use image::{codecs::png::PngEncoder, ColorType, ImageEncoder};
|
use image::{codecs::jpeg::JpegEncoder, ColorType, ImageEncoder};
|
||||||
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
|
||||||
|
|
||||||
|
use crate::{interaction::gizmos::TransformGizmo, world_grid::WorldGridPlane};
|
||||||
|
|
||||||
use super::{AsJsValue, StaticBuffer, WasmMessageBuffer};
|
use super::{AsJsValue, StaticBuffer, WasmMessageBuffer};
|
||||||
|
|
||||||
static SCREENSHOT_REQUEST: WasmMessageBuffer<()> = WasmMessageBuffer::new();
|
static SCREENSHOT_REQUEST: WasmMessageBuffer<()> = WasmMessageBuffer::new();
|
||||||
@@ -22,16 +24,25 @@ pub struct ScreenshotPlugin;
|
|||||||
|
|
||||||
impl Plugin for ScreenshotPlugin {
|
impl Plugin for ScreenshotPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_event::<ScreenshotRequestedEvent>();
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
take_screenshots.map(bevy::utils::error),
|
(
|
||||||
|
prepare_screenshot,
|
||||||
|
apply_deferred,
|
||||||
|
take_screenshots.map(bevy::utils::error),
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
send_screenshots.map(bevy::utils::error),
|
send_screenshots.map(bevy::utils::error),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Event)]
|
||||||
|
pub struct ScreenshotRequestedEvent;
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Screenshot {
|
pub struct Screenshot {
|
||||||
@@ -74,21 +85,46 @@ impl AsJsValue for Screenshot {
|
|||||||
|
|
||||||
#[wasm_bindgen(js_name = requestScreenshot)]
|
#[wasm_bindgen(js_name = requestScreenshot)]
|
||||||
pub fn request_screenshot() {
|
pub fn request_screenshot() {
|
||||||
|
info!("[wasm::screenshot] Screenshot request received");
|
||||||
SCREENSHOT_REQUEST.write_message(());
|
SCREENSHOT_REQUEST.write_message(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn prepare_screenshot(
|
||||||
|
mut q_editor_visuals: Query<&mut Visibility, Or<(With<WorldGridPlane>, With<TransformGizmo>)>>,
|
||||||
|
mut ew: EventWriter<ScreenshotRequestedEvent>,
|
||||||
|
) {
|
||||||
|
if SCREENSHOT_REQUEST.take_message().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[wasm::screenshot] Hiding {} editor visuals",
|
||||||
|
q_editor_visuals.iter().count()
|
||||||
|
);
|
||||||
|
|
||||||
|
for mut visibility in q_editor_visuals.iter_mut() {
|
||||||
|
*visibility = Visibility::Hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
ew.send(ScreenshotRequestedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
fn take_screenshots(
|
fn take_screenshots(
|
||||||
mut r_screenshot_mgr: ResMut<ScreenshotManager>,
|
mut r_screenshot_mgr: ResMut<ScreenshotManager>,
|
||||||
q_window: Query<Entity, With<PrimaryWindow>>,
|
q_window: Query<Entity, With<PrimaryWindow>>,
|
||||||
|
mut er: EventReader<ScreenshotRequestedEvent>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if SCREENSHOT_REQUEST.take_message().is_none() {
|
if er.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(window_ent) = q_window.get_single() else {
|
let Ok(window_ent) = q_window.get_single() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
er.clear();
|
||||||
|
|
||||||
|
info!("[wasm::screenshot] Queueing screenshot request");
|
||||||
|
|
||||||
r_screenshot_mgr
|
r_screenshot_mgr
|
||||||
.take_screenshot(window_ent, |img| {
|
.take_screenshot(window_ent, |img| {
|
||||||
let img = match img.try_into_dynamic().map(|img| img.to_rgb8()) {
|
let img = match img.try_into_dynamic().map(|img| img.to_rgb8()) {
|
||||||
@@ -112,8 +148,8 @@ fn take_screenshots(
|
|||||||
};
|
};
|
||||||
let src_buffer = &img.as_raw()[..buffer_len];
|
let src_buffer = &img.as_raw()[..buffer_len];
|
||||||
|
|
||||||
let mut png_bytes = vec![];
|
let mut jpg_bytes = vec![];
|
||||||
if let Err(err) = PngEncoder::new(&mut png_bytes).write_image(
|
if let Err(err) = JpegEncoder::new_with_quality(&mut jpg_bytes, 70).write_image(
|
||||||
src_buffer,
|
src_buffer,
|
||||||
img.width(),
|
img.width(),
|
||||||
img.height(),
|
img.height(),
|
||||||
@@ -124,7 +160,7 @@ fn take_screenshots(
|
|||||||
}
|
}
|
||||||
|
|
||||||
SCREENSHOT.write_message(ScreenshotNative {
|
SCREENSHOT.write_message(ScreenshotNative {
|
||||||
data: png_bytes,
|
data: jpg_bytes,
|
||||||
width: img.width() as usize,
|
width: img.width() as usize,
|
||||||
height: img.height() as usize,
|
height: img.height() as usize,
|
||||||
});
|
});
|
||||||
@@ -146,7 +182,10 @@ declare global {
|
|||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
fn send_screenshots(q_window: Query<&Window, With<PrimaryWindow>>) -> Result<(), String> {
|
fn send_screenshots(
|
||||||
|
q_window: Query<&Window, With<PrimaryWindow>>,
|
||||||
|
mut q_editor_visuals: Query<&mut Visibility, Or<(With<WorldGridPlane>, With<TransformGizmo>)>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let Some(screenshot) = SCREENSHOT.take_message() else {
|
let Some(screenshot) = SCREENSHOT.take_message() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -157,5 +196,16 @@ fn send_screenshots(q_window: Query<&Window, With<PrimaryWindow>>) -> Result<(),
|
|||||||
|
|
||||||
let screenshot = Screenshot::from(screenshot);
|
let screenshot = Screenshot::from(screenshot);
|
||||||
|
|
||||||
super::dispatch_to_js(window, "screenshot-ready", &screenshot)
|
super::dispatch_to_js(window, "screenshot-ready", &screenshot)?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[wasm::screenshot] Revealing {} previously hidden editor visuals",
|
||||||
|
q_editor_visuals.iter().count()
|
||||||
|
);
|
||||||
|
|
||||||
|
for mut visibility in q_editor_visuals.iter_mut() {
|
||||||
|
*visibility = Visibility::Inherited;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ impl Plugin for WorldGridPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
struct WorldGridPlane;
|
pub struct WorldGridPlane;
|
||||||
|
|
||||||
#[derive(Asset, AsBindGroup, TypePath, Clone)]
|
#[derive(Asset, AsBindGroup, TypePath, Clone)]
|
||||||
struct ProceduralGridMaterial {}
|
struct ProceduralGridMaterial {}
|
||||||
|
|||||||
Reference in New Issue
Block a user