#1 fix(vkdevice): accept images up to 8192 pixels per side
M internal/presentation/vkdevice/device.go +2 -0
| @@ -55,6 +55,7 @@ type Device struct { | |||
| 55 | 55 | Validation *Validation | |
| 56 | 56 | atlas *glyphAtlas | |
| 57 | 57 | images *imageCache | |
| 58 | + | imageDimension int // application ceiling clamped to the selected device | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 60 | 61 | func Open(main Identity) (_ *Device, err error) { | |
| @@ -136,6 +137,7 @@ func Open(main Identity) (_ *Device, err error) { | |||
| 136 | 137 | } | |
| 137 | 138 | } | |
| 138 | 139 | d.Physical = candidate | |
| 140 | + | d.imageDimension = int(min(uint32(maxImageDimension), props.Properties.Limits.MaxImageDimension2D)) | |
| 139 | 141 | d.Render = Identity{uint32(drm.RenderMajor), uint32(drm.RenderMinor)} | |
| 140 | 142 | break | |
| 141 | 143 | } | |
| @@ -55,6 +55,7 @@ type Device struct { | |||||
| 55 | Validation *Validation | 55 | Validation *Validation | ||
| 56 | atlas *glyphAtlas | 56 | atlas *glyphAtlas | ||
| 57 | images *imageCache | 57 | images *imageCache | ||
| 58 | + | imageDimension int // application ceiling clamped to the selected device | |||
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 60 | func Open(main Identity) (_ *Device, err error) { | 61 | func Open(main Identity) (_ *Device, err error) { | ||
| @@ -136,6 +137,7 @@ func Open(main Identity) (_ *Device, err error) { | |||||
| 136 | } | 137 | } | ||
| 137 | } | 138 | } | ||
| 138 | d.Physical = candidate | 139 | d.Physical = candidate | ||
| 140 | + | d.imageDimension = int(min(uint32(maxImageDimension), props.Properties.Limits.MaxImageDimension2D)) | |||
| 139 | d.Render = Identity{uint32(drm.RenderMajor), uint32(drm.RenderMinor)} | 141 | d.Render = Identity{uint32(drm.RenderMajor), uint32(drm.RenderMinor)} | ||
| 140 | break | 142 | break | ||
| 141 | } | 143 | } | ||
M internal/presentation/vkdevice/image_cache.go +3 -2
| @@ -87,6 +87,7 @@ func (c *imageCache) dispose(entries []*imageEntry) { | |||
| 87 | 87 | } | |
| 88 | 88 | func (d *Device) newImageCache() (c *imageCache, err error) { | |
| 89 | 89 | c = &imageCache{device: d, policy: newImageCachePolicy(), textures: make(map[*imageEntry]*gpuImage), done: make(map[uint64]bool), inflight: make(map[*Frame]struct{})} | |
| 90 | + | c.policy.maxDimension = d.imageDimension | |
| 90 | 91 | defer func() { | |
| 91 | 92 | if err != nil { | |
| 92 | 93 | c.Close() | |
| @@ -169,8 +170,8 @@ func (c *imageCache) resolve(f *Frame, batches []Batch) ([]Batch, error) { | |||
| 169 | 170 | seen[key] = true | |
| 170 | 171 | count++ | |
| 171 | 172 | b := key.Bounds() | |
| 172 | - | if b.Dx() <= 0 || b.Dy() <= 0 || b.Dx() > maxImageDimension || b.Dy() > maxImageDimension { | |
| 173 | - | return nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", b.Dx(), b.Dy(), maxImageDimension) | |
| 173 | + | if b.Dx() <= 0 || b.Dy() <= 0 || b.Dx() > c.policy.maxDimension || b.Dy() > c.policy.maxDimension { | |
| 174 | + | return nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", b.Dx(), b.Dy(), c.policy.maxDimension) | |
| 174 | 175 | } | |
| 175 | 176 | if e := c.policy.entries[key]; e != nil { | |
| 176 | 177 | bytes += e.bytes | |
| @@ -87,6 +87,7 @@ func (c *imageCache) dispose(entries []*imageEntry) { | |||||
| 87 | } | 87 | } | ||
| 88 | func (d *Device) newImageCache() (c *imageCache, err error) { | 88 | func (d *Device) newImageCache() (c *imageCache, err error) { | ||
| 89 | c = &imageCache{device: d, policy: newImageCachePolicy(), textures: make(map[*imageEntry]*gpuImage), done: make(map[uint64]bool), inflight: make(map[*Frame]struct{})} | 89 | c = &imageCache{device: d, policy: newImageCachePolicy(), textures: make(map[*imageEntry]*gpuImage), done: make(map[uint64]bool), inflight: make(map[*Frame]struct{})} | ||
| 90 | + | c.policy.maxDimension = d.imageDimension | |||
| 90 | defer func() { | 91 | defer func() { | ||
| 91 | if err != nil { | 92 | if err != nil { | ||
| 92 | c.Close() | 93 | c.Close() | ||
| @@ -169,8 +170,8 @@ func (c *imageCache) resolve(f *Frame, batches []Batch) ([]Batch, error) { | |||||
| 169 | seen[key] = true | 170 | seen[key] = true | ||
| 170 | count++ | 171 | count++ | ||
| 171 | b := key.Bounds() | 172 | b := key.Bounds() | ||
| 172 | - | if b.Dx() <= 0 || b.Dy() <= 0 || b.Dx() > maxImageDimension || b.Dy() > maxImageDimension { | 173 | + | if b.Dx() <= 0 || b.Dy() <= 0 || b.Dx() > c.policy.maxDimension || b.Dy() > c.policy.maxDimension { |
| 173 | - | return nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", b.Dx(), b.Dy(), maxImageDimension) | 174 | + | return nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", b.Dx(), b.Dy(), c.policy.maxDimension) |
| 174 | } | 175 | } | ||
| 175 | if e := c.policy.entries[key]; e != nil { | 176 | if e := c.policy.entries[key]; e != nil { | ||
| 176 | bytes += e.bytes | 177 | bytes += e.bytes | ||
M internal/presentation/vkdevice/image_cache_policy.go +5 -4
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | const maxImageTextures = 64 | |
| 10 | 10 | const imageDescriptorSlots = maxImageTextures + 1 // one fallback plus the 64-texture budget | |
| 11 | 11 | const maxImageBytes = 64 << 20 | |
| 12 | - | const maxImageDimension = 4096 | |
| 12 | + | const maxImageDimension = 8192 | |
| 13 | 13 | ||
| 14 | 14 | type imageEntry struct { | |
| 15 | 15 | key image.Image | |
| @@ -26,6 +26,7 @@ type imageEntry struct { | |||
| 26 | 26 | // only then may UpdateDescriptorSets overwrite the recycled set. The pool is | |
| 27 | 27 | // destroyed once at shutdown. This avoids requiring FreeDescriptorSets. | |
| 28 | 28 | type imageCachePolicy struct { | |
| 29 | + | maxDimension int | |
| 29 | 30 | entries map[image.Image]*imageEntry | |
| 30 | 31 | free []int | |
| 31 | 32 | retired []*imageEntry | |
| @@ -34,7 +35,7 @@ type imageCachePolicy struct { | |||
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | 37 | func newImageCachePolicy() *imageCachePolicy { | |
| 37 | - | p := &imageCachePolicy{entries: make(map[image.Image]*imageEntry)} | |
| 38 | + | p := &imageCachePolicy{entries: make(map[image.Image]*imageEntry), maxDimension: maxImageDimension} | |
| 38 | 39 | // Slot 0 is permanently reserved for the valid set 1 fallback. | |
| 39 | 40 | for i := imageDescriptorSlots - 1; i >= 1; i-- { | |
| 40 | 41 | p.free = append(p.free, i) | |
| @@ -72,8 +73,8 @@ func (p *imageCachePolicy) reserve(key image.Image) (*imageEntry, []*imageEntry, | |||
| 72 | 73 | } | |
| 73 | 74 | bounds := key.Bounds() | |
| 74 | 75 | w, h := bounds.Dx(), bounds.Dy() | |
| 75 | - | if w <= 0 || h <= 0 || w > maxImageDimension || h > maxImageDimension { | |
| 76 | - | return nil, nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", w, h, maxImageDimension) | |
| 76 | + | if w <= 0 || h <= 0 || w > p.maxDimension || h > p.maxDimension { | |
| 77 | + | return nil, nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", w, h, p.maxDimension) | |
| 77 | 78 | } | |
| 78 | 79 | if e := p.entries[key]; e != nil { | |
| 79 | 80 | e.used, e.lastSerial = p.frame, p.frame | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | const maxImageTextures = 64 | 9 | const maxImageTextures = 64 | ||
| 10 | const imageDescriptorSlots = maxImageTextures + 1 // one fallback plus the 64-texture budget | 10 | const imageDescriptorSlots = maxImageTextures + 1 // one fallback plus the 64-texture budget | ||
| 11 | const maxImageBytes = 64 << 20 | 11 | const maxImageBytes = 64 << 20 | ||
| 12 | - | const maxImageDimension = 4096 | 12 | + | const maxImageDimension = 8192 |
| 13 | 13 | ||||
| 14 | type imageEntry struct { | 14 | type imageEntry struct { | ||
| 15 | key image.Image | 15 | key image.Image | ||
| @@ -26,6 +26,7 @@ type imageEntry struct { | |||||
| 26 | // only then may UpdateDescriptorSets overwrite the recycled set. The pool is | 26 | // only then may UpdateDescriptorSets overwrite the recycled set. The pool is | ||
| 27 | // destroyed once at shutdown. This avoids requiring FreeDescriptorSets. | 27 | // destroyed once at shutdown. This avoids requiring FreeDescriptorSets. | ||
| 28 | type imageCachePolicy struct { | 28 | type imageCachePolicy struct { | ||
| 29 | + | maxDimension int | |||
| 29 | entries map[image.Image]*imageEntry | 30 | entries map[image.Image]*imageEntry | ||
| 30 | free []int | 31 | free []int | ||
| 31 | retired []*imageEntry | 32 | retired []*imageEntry | ||
| @@ -34,7 +35,7 @@ type imageCachePolicy struct { | |||||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | func newImageCachePolicy() *imageCachePolicy { | 37 | func newImageCachePolicy() *imageCachePolicy { | ||
| 37 | - | p := &imageCachePolicy{entries: make(map[image.Image]*imageEntry)} | 38 | + | p := &imageCachePolicy{entries: make(map[image.Image]*imageEntry), maxDimension: maxImageDimension} |
| 38 | // Slot 0 is permanently reserved for the valid set 1 fallback. | 39 | // Slot 0 is permanently reserved for the valid set 1 fallback. | ||
| 39 | for i := imageDescriptorSlots - 1; i >= 1; i-- { | 40 | for i := imageDescriptorSlots - 1; i >= 1; i-- { | ||
| 40 | p.free = append(p.free, i) | 41 | p.free = append(p.free, i) | ||
| @@ -72,8 +73,8 @@ func (p *imageCachePolicy) reserve(key image.Image) (*imageEntry, []*imageEntry, | |||||
| 72 | } | 73 | } | ||
| 73 | bounds := key.Bounds() | 74 | bounds := key.Bounds() | ||
| 74 | w, h := bounds.Dx(), bounds.Dy() | 75 | w, h := bounds.Dx(), bounds.Dy() | ||
| 75 | - | if w <= 0 || h <= 0 || w > maxImageDimension || h > maxImageDimension { | 76 | + | if w <= 0 || h <= 0 || w > p.maxDimension || h > p.maxDimension { |
| 76 | - | return nil, nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", w, h, maxImageDimension) | 77 | + | return nil, nil, fmt.Errorf("image cache: invalid dimensions %dx%d (maximum %d)", w, h, p.maxDimension) |
| 77 | } | 78 | } | ||
| 78 | if e := p.entries[key]; e != nil { | 79 | if e := p.entries[key]; e != nil { | ||
| 79 | e.used, e.lastSerial = p.frame, p.frame | 80 | e.used, e.lastSerial = p.frame, p.frame | ||
M internal/presentation/vkdevice/image_cache_policy_test.go +30 -1
| @@ -42,11 +42,40 @@ func TestImageCacheBudgetAndFenceReuse(t *testing.T) { | |||
| 42 | 42 | if _, _, err := p.reserve(nonComparableImage{data: []byte{1}}); err == nil || !strings.Contains(err.Error(), "comparable") { | |
| 43 | 43 | t.Fatalf("expected identity error: %v", err) | |
| 44 | 44 | } | |
| 45 | - | if _, _, err := p.reserve(image.NewRGBA(image.Rect(0, 0, 4097, 1))); err == nil || !strings.Contains(err.Error(), "maximum") { | |
| 45 | + | if _, _, err := p.reserve(image.NewRGBA(image.Rect(0, 0, maxImageDimension+1, 1))); err == nil || !strings.Contains(err.Error(), "maximum") { | |
| 46 | 46 | t.Fatalf("expected size error: %v", err) | |
| 47 | 47 | } | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | + | func TestImageCacheWideImageAndDeviceLimit(t *testing.T) { | |
| 51 | + | p := newImageCachePolicy() | |
| 52 | + | p.begin(1, 0) | |
| 53 | + | // Bounds alone suffice: reserve validates before reading pixels. | |
| 54 | + | wide := &image.RGBA{Rect: image.Rect(0, 0, 5120, 2160)} | |
| 55 | + | if _, _, err := p.reserve(wide); err != nil { | |
| 56 | + | t.Fatalf("ultrawide image: %v", err) | |
| 57 | + | } | |
| 58 | + | p.maxDimension = 4096 | |
| 59 | + | if _, _, err := p.reserve(wide); err == nil || !strings.Contains(err.Error(), "maximum 4096") { | |
| 60 | + | t.Fatalf("expected device limit: %v", err) | |
| 61 | + | } | |
| 62 | + | p.maxDimension = maxImageDimension | |
| 63 | + | tooLarge := &image.RGBA{Rect: image.Rect(0, 0, 7680, 4320)} | |
| 64 | + | if _, _, err := p.reserve(tooLarge); err == nil || !strings.Contains(err.Error(), "budget") { | |
| 65 | + | t.Fatalf("expected unchanged byte budget: %v", err) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestImageCacheResolveDeviceLimit(t *testing.T) { | |
| 70 | + | p := newImageCachePolicy() | |
| 71 | + | p.maxDimension = 4096 | |
| 72 | + | c := &imageCache{policy: p} | |
| 73 | + | wide := &image.RGBA{Rect: image.Rect(0, 0, 5120, 2160)} | |
| 74 | + | if _, err := c.resolve(&Frame{}, []Batch{{Source: wide}}); err == nil || !strings.Contains(err.Error(), "maximum 4096") { | |
| 75 | + | t.Fatalf("expected frame validation against device limit: %v", err) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 50 | 79 | func TestImageCacheLRUAndBytes(t *testing.T) { | |
| 51 | 80 | p := newImageCachePolicy() | |
| 52 | 81 | old := image.NewRGBA(image.Rect(0, 0, 2048, 2048)) | |
| @@ -42,11 +42,40 @@ func TestImageCacheBudgetAndFenceReuse(t *testing.T) { | |||||
| 42 | if _, _, err := p.reserve(nonComparableImage{data: []byte{1}}); err == nil || !strings.Contains(err.Error(), "comparable") { | 42 | if _, _, err := p.reserve(nonComparableImage{data: []byte{1}}); err == nil || !strings.Contains(err.Error(), "comparable") { | ||
| 43 | t.Fatalf("expected identity error: %v", err) | 43 | t.Fatalf("expected identity error: %v", err) | ||
| 44 | } | 44 | } | ||
| 45 | - | if _, _, err := p.reserve(image.NewRGBA(image.Rect(0, 0, 4097, 1))); err == nil || !strings.Contains(err.Error(), "maximum") { | 45 | + | if _, _, err := p.reserve(image.NewRGBA(image.Rect(0, 0, maxImageDimension+1, 1))); err == nil || !strings.Contains(err.Error(), "maximum") { |
| 46 | t.Fatalf("expected size error: %v", err) | 46 | t.Fatalf("expected size error: %v", err) | ||
| 47 | } | 47 | } | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | func TestImageCacheWideImageAndDeviceLimit(t *testing.T) { | |||
| 51 | + | p := newImageCachePolicy() | |||
| 52 | + | p.begin(1, 0) | |||
| 53 | + | // Bounds alone suffice: reserve validates before reading pixels. | |||
| 54 | + | wide := &image.RGBA{Rect: image.Rect(0, 0, 5120, 2160)} | |||
| 55 | + | if _, _, err := p.reserve(wide); err != nil { | |||
| 56 | + | t.Fatalf("ultrawide image: %v", err) | |||
| 57 | + | } | |||
| 58 | + | p.maxDimension = 4096 | |||
| 59 | + | if _, _, err := p.reserve(wide); err == nil || !strings.Contains(err.Error(), "maximum 4096") { | |||
| 60 | + | t.Fatalf("expected device limit: %v", err) | |||
| 61 | + | } | |||
| 62 | + | p.maxDimension = maxImageDimension | |||
| 63 | + | tooLarge := &image.RGBA{Rect: image.Rect(0, 0, 7680, 4320)} | |||
| 64 | + | if _, _, err := p.reserve(tooLarge); err == nil || !strings.Contains(err.Error(), "budget") { | |||
| 65 | + | t.Fatalf("expected unchanged byte budget: %v", err) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestImageCacheResolveDeviceLimit(t *testing.T) { | |||
| 70 | + | p := newImageCachePolicy() | |||
| 71 | + | p.maxDimension = 4096 | |||
| 72 | + | c := &imageCache{policy: p} | |||
| 73 | + | wide := &image.RGBA{Rect: image.Rect(0, 0, 5120, 2160)} | |||
| 74 | + | if _, err := c.resolve(&Frame{}, []Batch{{Source: wide}}); err == nil || !strings.Contains(err.Error(), "maximum 4096") { | |||
| 75 | + | t.Fatalf("expected frame validation against device limit: %v", err) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 50 | func TestImageCacheLRUAndBytes(t *testing.T) { | 79 | func TestImageCacheLRUAndBytes(t *testing.T) { | ||
| 51 | p := newImageCachePolicy() | 80 | p := newImageCachePolicy() | ||
| 52 | old := image.NewRGBA(image.Rect(0, 0, 2048, 2048)) | 81 | old := image.NewRGBA(image.Rect(0, 0, 2048, 2048)) | ||
Comments0 open / 0
no comments yet.