UstymUkhman / vite-plugin-glsl

:spider_web: Import, inline (and compress) GLSL shader files :electric_plug:
https://www.npmjs.com/package/vite-plugin-glsl
MIT License
321 stars 21 forks source link

HMR in vuejs working weirdly #54

Closed Makio64 closed 8 months ago

Makio64 commented 8 months ago

It seems vite-plugin-glsl hmr doesnt work straight forward in vite + vuejs + threejs.

I code a simple demo of how i make it works but some points seems weird to me :

Does it seems correct to you guys or do you have a suggestion to make it more clean ?

If this is the best solution i should probablu write a plugin for vue to make all this automatic?

Main view

<template>
    <div class="ThreeView">
        <div ref="title" class="title">THREEJS</div>
        <div ref="subtitle" class="subtitle">Simple example</div>
    </div>
</template>

<script>
import { Mesh, BoxGeometry, ShaderMaterial, Scene, WebGLRenderer, PerspectiveCamera, Vector3 } from 'three'
import {stage } from '@/makio/core/stage'
import {basicFS} from '@/3d/shaders/shaderLoader'

// import basicFrag from '@/3d/shaders/basic.frag'

export default {
    name: 'ThreeView',
    mounted() {
        this.init()
    },
    methods:{
        init(){
            console.log('init')
            this.renderer = new WebGLRenderer({})
            this.renderer.setPixelRatio(stage.devicePixelRatio)
            this.renderer.setSize(stage.width, stage.height)
            this.renderer.domElement.className = 'three'
            document.body.appendChild(this.renderer.domElement)
            this.scene = new Scene()
            this.camera = new PerspectiveCamera(50, stage.width / stage.height, 0.1, 100)
            this.camera.position.z = 5
            this.camera.updateMatrixWorld(true)
            this.camera.lookAt(new Vector3(0,0,0))
            this.cube = new Mesh(new BoxGeometry(1, 1, 1), new ShaderMaterial({ fragmentShader:basicFS.value }))
            watch(basicFS,(value,oldValue)=>{
                this.cube.material.fragmentShader = value
                this.cube.material.needsUpdate = true
            })
            this.scene.add(this.cube)
            stage.onUpdate.add(this.update)
            this.isInit = true
        },
        update(dt){
            this.renderer.render(this.scene, this.camera)
        }
    },
}
</script>

<style lang="stylus" scoped>
.ThreeView
    color #fff
    display flex
    min-height 100%
    flex-direction column
    justify-content center
    align-items center
    .title
        font-size 3rem
    .subtitle
        font-size 1.5rem
</style>

shaderLoader.js

import { ref } from 'vue';

import basic from './basic.frag';
const basicFS = ref(basic);

if (import.meta.hot) {
  import.meta.hot.accept('./basic.frag', (newModule) => {
    basicFS.value = newModule.default;
  })
}

export {basicFS};
UstymUkhman commented 8 months ago

Hi @Makio64 and thanks for using this plugin!

Maybe I'm missing something, but I made a quick test and it works pretty well for me. I've attached a simple example based on your code it in this comment.

You can play around with cube color (in cube.frag, on line 10 & 11) and see it changing in the scene by leveraging HMR. The trick is to dispose your scene in Vue's onBeforeUnmount callback, otherwise after each HMR a new canvas is created and added to the DOM. If you remove onBeforeUnmount, you'll be able to see it outside of the viewport by scrolling right. Maybe that is related to the issue you're having?

Please, let me know if this was the problem and if I can help further. Cheers.

my-vue-app.zip

Makio64 commented 8 months ago

Thanks for your example!

I just try it and the difference is the unmount / mount on App is triggered in your code which is what I wanted to avoid, with the code I shared its not the case, only the material is updated, nothing else change.

If we think in terms of css, when you update your css with HMR the component is not unmount/mount only the css change, I wanted the same behavior with the shader.

It feels more seemless than recreated everything and in a case of a game or heavy animated site for example I wanna edit the shader without loosing the current state.

UstymUkhman commented 8 months ago

I see your point now...

The reason why the whole scene gets recreated seems to be related to the fact that only createFileOnlyEntry is used to handle GLSL changes. It was first introduced by @brunoimbrizi here and it was a huge improvement to the HMR for this plugin. However, as far as I've understood from my quick research, this should be done only for new file entries, and when changing one already present in the moduleGraph, we should (probably) call ensureEntryFromUrl just like vite's CSS plugin does here..?

I'm still not 100% sure about that, this enhancement needs more investigation. Probably it's also worth to take a look at Rollup's load and shouldTransformCachedModule APIs along with a recap on Vite's Plugin APIs.

If done correctly, this can be a great improvement over the current functionality, so I'll definitely will get back to this in the next few days. Thanks again for your feedback @Makio64!

Btw, any help would be much appreciated.

Makio64 commented 8 months ago

For quick fix my use case I can create a plugin for vite-vue-glsl which create this part of the code automatically for every .fs/.vs/... :

import { ref } from 'vue';

import basic from './basic.frag';
const basicFS = ref(basic);

if (import.meta.hot) {
  import.meta.hot.accept('./basic.frag', (newModule) => {
    basicFS.value = newModule.default;
  })
}

export {basicFS};

then the user can just import it instead of import ref instead of the .fs/.vs/... like import {basicFS} from 'shaders' and use the basicFS.value

About the watch part I also want to make it automatic but not sure what will be the best approach, extends the material to handle it or keep reference of material using this shader and force their update with something like updateOnChange(material, shaderRef) ? Both didnt seems really convincing.

PS: hi @brunoimbrizi 🀟 !

Makio64 commented 8 months ago

Reference from @raphaelameaume hot-shader-replacement : https://github.com/raphaelameaume/fragment/blob/dev/src/cli/plugins/hot-shader-replacement.js

UstymUkhman commented 8 months ago

Hi again @Makio64!

I made some deeper research on my side about how HMR actually works in vite, and I think I might have lost the initial goal of what we wanted to achieve here, so I might need you help once again. πŸ˜… Let me explain.

I though that hot module replacement was not fired correctly because there wasn't a handleHotUpdate hook defined in this plugin, so I've implemented it. However, after some testing I realized there's (probably) no need to do that because even in the current version of vite-plugin-glsl, HMR can be used without it by handling it on client side, in, like so (example is based on my-vue-app.zip I've attached above):

// Added at the bottom of the script tag in App.vue:
if (import.meta.hot) {
  import.meta.hot.accept('./cube.frag', (glsl) => {
    cube.material.fragmentShader = glsl.default;
    cube.material.needsUpdate = true;
  });
}

This works without reloading the page when you change anything in cube.frag or even in other shader chunks included in cube.frag. This is based on your example (thanks a lot for providing it) but without using another js file (your first point) and without any ref of the source code (I don't understand why it should be necessary), just replacing it on the fly.

As for your second point,

I have to handle manually the hotreloading of the material using a watch from vue

which I believe is related to this:

About the watch part I also want to make it automatic but not sure what will be the best approach, extends the material to handle it or keep reference of material using this shader and force their update with something like updateOnChange(material, shaderRef) ? Both didnt seems really convincing.

I think that extending a material is a quite good idea. I mean, I would handle it with this technique. Similar to what's done here, I would add a import.meta.hot.accept callback somewhere in the class for custom shaders used by that material, It will update it's fragmentShader (and vertexShader) on fly and would prevent the page reload when that happens.

Since this plugin is aimed to be framework agnostic, I believe this would give more freedom to developers to handle shader changes on client side properly whether it's three.js, Babylon.js and so on.., or just reload the page by default. Please let me know what do you think about this and if I've missed something is your requirements. Thanks!

Makio64 commented 8 months ago

Thanks @UstymUkhman , I'll test it to check why I used extra ref/file.

I agree if you want to keep it crossplatform better not add more logic here but would be good to provide minimal usage example for common usecases and libs.

Still I like to not have any extra code for hmr work with my lib and let plugin do the magic for me (I will do a lot of effort to not do effort the rest of my life :D). What Raphael did in his Fragment.js to automatically update the shader without adding more code in three or p5 looks like the right direction.

Probably the best to acheive this overhead is an extra plugin vite-plugin-glsl-threejs using internally vite-plugin-glsl and specific to each framework ( three / babylon / p5 .. )

UstymUkhman commented 8 months ago

Great! I'm glad I could help.

I agree if you want to keep it crossplatform better not add more logic here but would be good to provide minimal usage example for common usecases and libs.

Yes! I was thinking about this and now I'm strongly considering to work on a such page/documentation in near future.

Still I like to not have any extra code for hmr work with my lib and let plugin do the magic for me (I will do a lot of effort to not do effort the rest of my life :D). What Raphael did in his Fragment.js to automatically update the shader without adding more code in three or p5 looks like the right direction.

Yeah, I can totally understand that and I wish I could provide something generic to satisfy every library that might be using this plugin, but at the moment I haven't any good solution to achieve this. I was considering to inject these few lines of boilerplate into js that imports a shader:

if (import.meta.hot)
  import.meta.hot.accept(

but than what? Every application will handle shader updates diferently, so there's no way to unify HMR callbacks on plugin level, that's something very client-specific. The advantage Raphael has is that he's also managing scene updates on client side, so he can just trigger a custom event (shader-update) and update all involved materials by traversing a scene. I'm not a fan of this approach (I prefer more "chirurgical" ones), but I can totally understand why he did it: he's aware that three.js is used and how is structured his scene graph.

Probably the best to acheive this overhead is an extra plugin vite-plugin-glsl-threejs using internally vite-plugin-glsl and specific to each framework ( three / babylon / p5 .. )

That's an option... To be honest I would avoid spamming multiple plugins on top of this one just because of 2 lines of overhead, but yeah, that's an option...

P.S.: Please, correct me if i'm wrong here, but your use case is a pretty specific one, right? I mean, when working on a scene within any JS framework, sooner or later you'll have to update some JavaScript, right? I mean it's not going to be just GLSL all the time... so when you'll update any JS, a new canvas will be added to the page, just like in your initial example. Wouldn't it be better to handle this with onBeforeUnmount or similar hooks during the development? Maybe it's a stupid question, but I just want to fully understand your use case. πŸ™‚

Makio64 commented 8 months ago

P.S.: Please, correct me if i'm wrong here, but your use case is a pretty specific one, right? I mean, when working on a scene within any JS framework, sooner or later you'll have to update some JavaScript, right? I mean it's not going to be just GLSL all the time... so when you'll update any JS, a new canvas will be added to the page, just like in your initial example. Wouldn't it be better to handle this with onBeforeUnmount or similar hooks during the development? Maybe it's a stupid question, but I just want to fully understand your use case. πŸ™‚

I cross this 'issue' while doing my monthly maintenance on my generic boilerplate im using on all my projects ( threejs / pixijs / pro or personnal / etc.. ). Usually HRM dont force to recreate a new canvas at every change, of course if main logic change it will reload. Still on all projects I used custom shaders intensively and often tweak them, here HMR without overhead would be a nice improvement of the workflow gaining fews seconds each time. Yeah, while waiting for the perfect solution adding fews lines when I know I will work a lot on a specific shader isnt a issue, its just pur coding comfort/productivity and to not redoing it everytime.

UstymUkhman commented 8 months ago

Closing this one for now. We can get back to it once some good enough, library independent solution pops up. 🀞