maxence-charriere / go-app

A package to build progressive web apps with Go programming language and WebAssembly.
https://go-app.dev
MIT License
7.93k stars 363 forks source link

app.Canvas Poor performance #714

Closed gepengscu closed 2 years ago

gepengscu commented 2 years ago

These are two examples of canvas. One is written in HTML+js and the other is written in go-app with app.Canvas. Is there any way to optimize the performance of app.Canvas?

This is html+js.


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Transform</title>
</head>
<body>

<canvas id="canvas" width="400" height="400" style="border:1px solid #d3d3d3;background-color: transparent">Update your
    browser
</canvas>
<script>
    bkg = document.createElement("canvas");
    bkg.width = canvas.width;
    bkg.height = canvas.height;
    bgc = bkg.getContext("2d");
    bgc.beginPath();
    bgc.arc(200, 200, 100, 0, Math.PI * 2);
    bgc.fillStyle = "yellow"
    bgc.fillRect(100, 10, 40, 30);
    bgc.stroke();

    ctx = canvas.getContext('2d');
    // ctx.drawImage(bkg, 0, 0, canvas.width, canvas.height)
    // ctx.drawImage(bkg, 100, 100, canvas.width, canvas.height)

    canvas.addEventListener("mousemove", function (e) {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(bkg, e.x-canvas.width/2, e.y-canvas.height/2, bkg.width, bkg.height);
        ctx.beginPath();
        ctx.moveTo(0, e.y);
        ctx.lineTo(canvas.width, e.y);
        ctx.moveTo(e.x, 0);
        ctx.lineTo(e.x, canvas.height);
        ctx.stroke();
        ctx.ellipse()
    })
</script>
</body>
</html>

This is go-app.


type Ctrl struct {
    app.Compo
    fCanvas app.Value
}

func (this *Ctrl) OnMount(ctx app.Context) {
    this.fCanvas = app.Window().GetElementByID("canvas").Call("getContext", "2d")
}
func (this *Ctrl) Render() app.UI {
    return app.Canvas().
        ID("canvas").
        Width(300).
        Height(300).
        OnMouseMove(
            func(ctx app.Context, e app.Event) {
                x := e.Get("offsetX").Int()
                y := e.Get("offsetY").Int()
                this.fCanvas.Call("clearRect", 0, 0, 300, 300)
                this.fCanvas.Call("beginPath")
                this.fCanvas.Call("moveTo", 0, 0)
                this.fCanvas.Call("lineTo", x, y)
                this.fCanvas.Call("stroke")
            },
        )
}
oderwat commented 2 years ago

I didn't check your code but I use a loop with app.Window().Call("requestAnimationFrame", renderFrame) in an async function to render my canvas example (a simple pong game) and stop it using a channel on dismount.

oderwat commented 2 years ago

You could also try my PR #711 and use "ctx.SkipAllUpdates()" in your "OnMouseMove()" handler. That is what I would try first.

You can do this by adding:

replace github.com/maxence-charriere/go-app/v9 => github.com/metatexx/go-app/v9 v9.4.2-0.20220405155713-0d5b7519f640

at the end of your go mod file (which also contains all my other PRs like loading progress indicator and custom tags).

oderwat commented 2 years ago

I still had some time before I have to leave the office so I quickly tried your canvas example and even with just your original code, I don't see the performance problem. What am I supposed to find a problem with it?

canvas-test1

This is my canvas example:

canvas-test2

(both of them are only at about 30 fps because of the recorder)

gepengscu commented 2 years ago

Thank you very much for your quick reply!Did you compare it with the HTML+js example?

gepengscu commented 2 years ago

When the mouse moves, the flicker is almost invisible in the HTML example, but it is more obvious in the app.Canvas example.

oderwat commented 2 years ago

What flicker? I recreated your whole javascript version:

package canvas

import (
    "github.com/maxence-charriere/go-app/v9/pkg/app"
    "math"
)

type Ctrl struct {
    app.Compo
    width    int
    height   int
    fCanvas  app.Value
    bgCanvas app.Value
}

func (c *Ctrl) OnInit() {
    c.width = 400
    c.height = 400
}

func (c *Ctrl) OnMount(ctx app.Context) {
    c.fCanvas = app.Window().GetElementByID("canvas").Call("getContext", "2d")

    bkg := app.Window().Get("document").Call("createElement", "canvas")
    bkg.Set("width", c.width)
    bkg.Set("height", c.height)

    bgc := bkg.Call("getContext", "2d")
    bgc.Call("beginPath")
    bgc.Call("arc", c.width/2, c.height/2, 100, 0, math.Pi*2)
    bgc.Set("fillStyle", "yellow")
    bgc.Call("fillRect", 100, 10, 40, 30)
    bgc.Call("stroke")
    c.bgCanvas = bkg
}
func (c *Ctrl) Render() app.UI {
    return app.Div().Body(app.Canvas().
        ID("canvas").
        Width(c.width).
        Height(c.height).
        OnMouseMove(
            func(ctx app.Context, e app.Event) {
                x := e.Get("offsetX").Int()
                y := e.Get("offsetY").Int()
                c.fCanvas.Call("clearRect", 0, 0, c.width, c.height)
                if c.bgCanvas.Truthy() {
                    c.fCanvas.Call("drawImage", c.bgCanvas, x-c.width/2, y-c.height/2, c.width, c.height)
                }
                c.fCanvas.Call("beginPath")
                c.fCanvas.Call("moveTo", 0, y)
                c.fCanvas.Call("lineTo", c.width, y)
                c.fCanvas.Call("moveTo", x, 0)
                c.fCanvas.Call("lineTo", x, c.height)
                c.fCanvas.Call("stroke")
                c.fCanvas.Call("beginPath")
                c.fCanvas.Call("ellipse", x, y, 100, 100, math.Pi/4, 0, 2*math.Pi)
                c.fCanvas.Call("stroke")
                //ctx.SkipAllUpdates()
            },
        ))
}

This is a screen recording of the HTML and the Go-App variant:

https://user-images.githubusercontent.com/719156/162220832-bcc22645-3634-4031-814c-9c9c9d66d828.mp4

oderwat commented 2 years ago

But if you really want "no flicker" I still think you should use requestAnimationFrame and render each (possible) frame in there.

oderwat commented 2 years ago

Here is my little pong example (it scales and keeps timing but this is not perfect):

package canvas

import (
    "math"
    "math/rand"

    "github.com/google/uuid"

    "github.com/metatexx/go-app-pkgs/dbg"

    "github.com/maxence-charriere/go-app/v9/pkg/app"
)

type renderChan chan struct{}

type Canvas struct {
    app.Compo
    Width   float64
    Height  float64
    Id      string
    done    renderChan
    running bool
}

var _ app.Composer = (*Canvas)(nil)
var _ app.Mounter = (*Canvas)(nil)
var _ app.Dismounter = (*Canvas)(nil)

func (c *Canvas) OnInit() {
    dbg.Log()
    c.Id = "cvs:" + uuid.NewString()
}

func (c *Canvas) OnDismount() {
    dbg.Log()
    // tell our go routine that we don't need it anymore
    c.running = false // comment this out to see how it gets ugly :)
}

func (c *Canvas) OnMount(ctx app.Context) {
    dbg.Log()
    cvEl := app.Window().GetElementByID(c.Id)
    cvCtx := cvEl.Call("getContext", "2d")

    // run "endless"
    c.done = make(chan struct{})
    ctx.Async(func() {
        scale := c.Width / 640.0
        s := 10.0 * scale
        x := float64(rand.Intn(int(640-50))+25) * scale
        y := float64(rand.Intn(int(480-10))+5) * scale
        ph := 80.0 * c.Width / 640
        speed := 200.0 * scale
        dx, dy := 0.21, 0.42 // speed
        dx += float64(rand.Intn(100)/100) - 0.5
        w := cvEl.Get("width").Float()
        h := cvEl.Get("height").Float()
        var renderFrame app.Func
        var tlast float64
        c.running = true
        var tcount = 0
        renderFrame = app.FuncOf(func(this app.Value, args []app.Value) interface{} {
            now := args[0].Float()
            tdiff := now - tlast
            tlast = now
            tcount++
            if tcount%100 == 0 {
                dbg.Logf("count %d", tcount)
            }

            cvCtx.Call("clearRect", 0, 0, w, h)
            cvCtx.Call("beginPath")
            cvCtx.Set("globalAlpha", 1)
            cvCtx.Set("lineWidth", 5*scale)
            cvCtx.Set("strokeStyle", "#202020")
            cvCtx.Set("fillStyle", "#e0e0e0")
            cvCtx.Call("rect", 0, 0, w, h)
            cvCtx.Call("fill")
            cvCtx.Call("stroke")

            cvCtx.Call("beginPath")
            cvCtx.Call("setLineDash", []interface{}{5})
            cvCtx.Set("globalAlpha", 0.5)
            cvCtx.Set("lineWidth", 3*scale)
            cvCtx.Call("moveTo", w/2+2.5, 0)
            cvCtx.Call("lineTo", w/2+2.5, h)
            cvCtx.Call("stroke")

            ppos := y - ph/2
            if ppos < 5 {
                ppos = 5
            } else if (ppos + ph) > h-5 {
                ppos = h - ph - 5
            }

            cvCtx.Call("beginPath")
            cvCtx.Call("setLineDash", []interface{}{})
            cvCtx.Set("fillStyle", "#30A030")
            cvCtx.Set("lineWidth", 2)
            cvCtx.Set("globalAlpha", 1.0)
            cvCtx.Call("rect", 15*scale, ppos, 10*scale, ph)
            cvCtx.Call("fill")

            cvCtx.Call("beginPath")
            cvCtx.Set("lineWidth", 2)
            cvCtx.Set("fillStyle", "#3030A0")
            cvCtx.Call("rect", w-25*scale, ppos, 10*scale, ph)
            cvCtx.Call("fill")

            cvCtx.Set("globalAlpha", 1.0)
            cvCtx.Call("beginPath")
            cvCtx.Set("fillStyle", "#A00000")
            cvCtx.Set("strokeStyle", "#000000")
            cvCtx.Call("setLineDash", []interface{}{})
            cvCtx.Call("arc", x, y, s, 0, 2*math.Pi)
            cvCtx.Call("fill")
            sdx := dx * speed / tdiff
            sdy := dy * speed / tdiff

            if (x+sdx) > w-25*scale-s || (x+sdx) < s+25*scale {
                dx *= -1
                sdx *= -1
            }
            if (y+sdy) > h-s || (y+sdy) < s {
                dy *= -1
                sdy *= -1
            }
            x += sdx
            y += sdy
            if c.running {
                app.Window().Call("requestAnimationFrame", renderFrame)
            } else {
                close(c.done)
            }
            return nil
        })
        defer renderFrame.Release()
        // start the rendering
        app.Window().Call("requestAnimationFrame", renderFrame)
        // wait till the channel gets closed
        <-c.done
        dbg.Log("closed")
    })
}

func (c *Canvas) Render() app.UI {
    dbg.Log()
    if c.Width == 0 {
        if c.Height == 0 {
            c.Width = 640
        } else {
            c.Width = math.Floor(c.Height / 3 * 4)
        }
    }
    if c.Height == 0 {
        if c.Width == 0 {
            c.Height = 480
        } else {
            c.Height = math.Floor(c.Width / 4 * 3)
        }
    }
    return app.Canvas().ID(c.Id).Width(int(c.Width)).Height(int(c.Height))
}
oderwat commented 2 years ago

And if you still have problems with the performance, just use the Javascript version in your app. There is no reason which keeps you back to using god old Javascript. This is one of the reasons, why we actually consider using go-app for clients (if the upfront load time is accepted).

gepengscu commented 2 years ago

I think I found the reason. The test component does not blink when running alone, but it appears when the component is embedded in a slightly more complex page. I'm trying to build a component library and migrate an old C / s system. This test component is embedded in a large test demo. When canvas is updated, all the parent components are updated. I'm looking forward to your Skipallupdates PR, which may solve this problem.

oderwat commented 2 years ago

I'm looking forward to your Skipallupdates PR, which may solve this problem.

Just try it out. It's just adding the line in the mod file. I would love to find more people supporting this PR.

gepengscu commented 2 years ago

The problem is that the parent component has not been modified. If I only update the canvas component, other components should not be updated according to the "deep compare" logic of go app. The parent component seems to be updated only because the style has been changed from width = "100%" height = "100%" to height = "100%" width = "100%" Is it because the map is unordered? But "deep compare" traverses the keys for map object.

oderwat commented 2 years ago

should not be updated

I am not sure what you mean by updated. Go-App as-is will call render on your component and any parent to create the "updated state" and then should only modify the dom when something is changed when compared to that new state.

Besides all of this, I still believe it is not the right thing to render on mousemoves instead on frame changes.

gepengscu commented 2 years ago

For some reason, onMouseMove triggered wasm exec. js:22. When the mouse moves, wasm exec. js:22 constantly prints empty information on the console, which consumes a lot of resources, resulting in the aforementioned refresh delay problem.

oderwat commented 2 years ago

This wasm_exec seems to be from the filesystem layer "writeSync()" which writes to the console.

I believe that you actually have a bug in your code. I do not get any output from the wasm_exe.js besides my debugging and the service worker logging when running apps. This includes using OnMouseMove() and running the component nested deep as a child of my application frameworks controller component.

Maybe you can add dbg.Log() in your code to narrow down where the output is generated. I have dbg.Log() in the head of about every single component method, so I can see exactly what is getting called and in which order. It loogs the calling function and file so it is easy to trace things with it.

It looks similar to this:

main.(*appControl).Render
main.(*TplSide).Render: form
github.com/metatexx/skelly/pkg/components/combobox.(*ComboBox).Render
github.com/metatexx/skelly/pkg/components/combobox.(*ComboBox).apiGetOptions: 
github.com/metatexx/skelly/pkg/components/combobox.(*ComboBox).Render
github.com/metatexx/skelly/pkg/pages.(*Form).OnMount
github.com/metatexx/skelly/pkg/components/combobox.(*ComboBox).Render

I also have it in my canvas example earlier in the comments.

gepengscu commented 2 years ago

The problem is found. There is an fmt.Println hidden in the range. Anyway, thank you very much!

app.Range(this.FItems).Slice(
    func(i int) app.UI {
        ui := this.FItems[i].(IMenuNode)
                fmt.Println(ui.Title) // bug here
                ui.SetMenu(this)
        ui.SetIdent(this.FIdent)
        return this.FItems[i].(app.UI)
    }),
gepengscu commented 2 years ago

This is a test example based on your suggestion. Very nice!


type Ctrl3 struct {
    app.Compo
    fCanvas  app.Value
    bgCanvas app.Value
    width    int
    height   int
    x        int
    y        int
    running  bool
}

func (this *Ctrl3) OnInit() {
    this.width = 800
    this.height = 800
}

func (c *Ctrl3) OnMount(ctx app.Context) {
    c.fCanvas = app.Window().GetElementByID("canvas")
    c.invalid(ctx)
}

func (c *Ctrl3) invalid(ctx app.Context) {
    if c.running {
        return
    }
    ctx.Async(
        func() {
            done := make(chan struct{})
            var renderFrame app.Func
            renderFrame = app.FuncOf(
                func(v app.Value, args []app.Value) interface{} {
                    c.running = true
                    if c.fCanvas.Truthy() {
                        c.paint(graph.Canvas(c.fCanvas))
                    }
                    close(done)
                    return nil
                })
            defer renderFrame.Release()
            // start the rendering
            app.Window().Call("requestAnimationFrame", renderFrame)
            <-done
            c.running = false
        })
}

func (c *Ctrl3) paint(canvas graph.ICanvas) {
    canvas.ClearRect(0, 0, c.width, c.height).
        BeginPath().
        MoveTo(0, 0).
        LineTo(0, c.height).
        LineTo(c.width, c.height).
        LineTo(c.width, 0).
        LineTo(0, 0).
        LineTo(c.x, c.y).
        MoveTo(c.x, 0).
        LineTo(c.x, c.height).
        MoveTo(0, c.y).
        LineTo(c.width, c.y).
        Stroke()
    canvas.BeginPath().MoveTo(c.x, c.y)
    for i := 0; i < 10000; i++ {
        x, y := rand.Intn(c.width), rand.Intn(c.width)
        canvas.LineTo(x, y).Arc(x, y, rand.Intn(20), 0, math.Pi*2)
    }
    canvas.Stroke()
}

func (c *Ctrl3) Render() app.UI {
    return app.Canvas().
        ID("canvas").
        Style("cursor", "none").
        Width(c.width).
        Height(c.height).
        OnMouseMove(
            func(ctx app.Context, e app.Event) {
                c.x = e.Get("offsetX").Int()
                c.y = e.Get("offsetY").Int()
                c.invalid(ctx)
            },
        )
}